Refactor commands (#1847)

Kujtim Hoxha created

Change summary

internal/commands/commands.go       | 213 +++++++++++++++++
internal/ui/dialog/actions.go       |  20 +
internal/ui/dialog/commands.go      | 386 ++++++++++++++----------------
internal/ui/dialog/commands_item.go |  39 ++
internal/ui/model/ui.go             |  97 ++++++
internal/uicmd/uicmd.go             |   1 
6 files changed, 523 insertions(+), 233 deletions(-)

Detailed changes

internal/commands/commands.go 🔗

@@ -0,0 +1,213 @@
+package commands
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/home"
+)
+
+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
+
+const (
+	userCommandPrefix    = "user:"
+	projectCommandPrefix = "project:"
+)
+
+// Argument represents a command argument with its name and required status.
+type Argument struct {
+	Name     string
+	Required bool
+}
+
+// MCPCustomCommand represents a custom command loaded from an MCP server.
+type MCPCustomCommand struct {
+	ID        string
+	Name      string
+	Client    string
+	Arguments []Argument
+}
+
+// CustomCommand represents a user-defined custom command loaded from markdown files.
+type CustomCommand struct {
+	ID        string
+	Name      string
+	Content   string
+	Arguments []Argument
+}
+
+type commandSource struct {
+	path   string
+	prefix string
+}
+
+// LoadCustomCommands loads custom commands from multiple sources including
+// XDG config directory, home directory, and project directory.
+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
+	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})
+			}
+
+			commands = append(commands, MCPCustomCommand{
+				ID:        key,
+				Name:      prompt.Name,
+				Client:    mcpName,
+				Arguments: args,
+			})
+		}
+	}
+	return commands, nil
+}
+
+func buildCommandSources(cfg *config.Config) []commandSource {
+	var sources []commandSource
+
+	// XDG config directory
+	if dir := getXDGCommandsDir(); dir != "" {
+		sources = append(sources, commandSource{
+			path:   dir,
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Home directory
+	if home := home.Dir(); home != "" {
+		sources = append(sources, commandSource{
+			path:   filepath.Join(home, ".crush", "commands"),
+			prefix: userCommandPrefix,
+		})
+	}
+
+	// Project directory
+	sources = append(sources, commandSource{
+		path:   filepath.Join(cfg.Options.DataDirectory, "commands"),
+		prefix: projectCommandPrefix,
+	})
+
+	return sources
+}
+
+func loadAll(sources []commandSource) ([]CustomCommand, error) {
+	var commands []CustomCommand
+
+	for _, source := range sources {
+		if cmds, err := loadFromSource(source); err == nil {
+			commands = append(commands, cmds...)
+		}
+	}
+
+	return commands, nil
+}
+
+func loadFromSource(source commandSource) ([]CustomCommand, error) {
+	if err := ensureDir(source.path); err != nil {
+		return nil, err
+	}
+
+	var commands []CustomCommand
+
+	err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
+		if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
+			return err
+		}
+
+		cmd, err := loadCommand(path, source.path, source.prefix)
+		if err != nil {
+			return nil // Skip invalid files
+		}
+
+		commands = append(commands, cmd)
+		return nil
+	})
+
+	return commands, err
+}
+
+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return CustomCommand{}, err
+	}
+
+	id := buildCommandID(path, baseDir, prefix)
+
+	return CustomCommand{
+		ID:        id,
+		Name:      id,
+		Content:   string(content),
+		Arguments: extractArgNames(string(content)),
+	}, nil
+}
+
+func extractArgNames(content string) []Argument {
+	matches := namedArgPattern.FindAllStringSubmatch(content, -1)
+	if len(matches) == 0 {
+		return nil
+	}
+
+	seen := make(map[string]bool)
+	var args []Argument
+
+	for _, match := range matches {
+		arg := match[1]
+		if !seen[arg] {
+			seen[arg] = true
+			// for normal custom commands, all args are required
+			args = append(args, Argument{Name: arg, Required: true})
+		}
+	}
+
+	return args
+}
+
+func buildCommandID(path, baseDir, prefix string) string {
+	relPath, _ := filepath.Rel(baseDir, path)
+	parts := strings.Split(relPath, string(filepath.Separator))
+
+	// Remove .md extension from last part
+	if len(parts) > 0 {
+		lastIdx := len(parts) - 1
+		parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
+	}
+
+	return prefix + strings.Join(parts, ":")
+}
+
+func getXDGCommandsDir() string {
+	xdgHome := os.Getenv("XDG_CONFIG_HOME")
+	if xdgHome == "" {
+		if home := home.Dir(); home != "" {
+			xdgHome = filepath.Join(home, ".config")
+		}
+	}
+	if xdgHome != "" {
+		return filepath.Join(xdgHome, "crush", "commands")
+	}
+	return ""
+}
+
+func ensureDir(path string) error {
+	if _, err := os.Stat(path); os.IsNotExist(err) {
+		return os.MkdirAll(path, 0o755)
+	}
+	return nil
+}
+
+func isMarkdownFile(name string) bool {
+	return strings.HasSuffix(strings.ToLower(name), ".md")
+}

internal/ui/dialog/actions.go 🔗

@@ -3,6 +3,7 @@ package dialog
 import (
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/session"
@@ -39,6 +40,8 @@ type (
 	ActionToggleThinking    struct{}
 	ActionExternalEditor    struct{}
 	ActionToggleYoloMode    struct{}
+	// ActionInitializeProject is a message to initialize a project.
+	ActionInitializeProject struct{}
 	ActionSummarize         struct {
 		SessionID string
 	}
@@ -46,6 +49,23 @@ type (
 		Permission permission.PermissionRequest
 		Action     PermissionAction
 	}
+	// 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
+		Arguments []commands.Argument
+	}
 )
 
 // Messages for API key input dialog.

internal/ui/dialog/commands.go 🔗

@@ -1,9 +1,7 @@
 package dialog
 
 import (
-	"fmt"
 	"os"
-	"slices"
 	"strings"
 
 	"charm.land/bubbles/v2/help"
@@ -12,15 +10,11 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
-	"github.com/charmbracelet/crush/internal/agent"
+	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
-	"github.com/charmbracelet/crush/internal/csync"
-	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uicmd"
-	"github.com/charmbracelet/crush/internal/uiutil"
 	uv "github.com/charmbracelet/ultraviolet"
 	"github.com/charmbracelet/x/ansi"
 )
@@ -28,6 +22,20 @@ import (
 // CommandsID is the identifier for the commands dialog.
 const CommandsID = "commands"
 
+// CommandType represents the type of commands being displayed.
+type CommandType uint
+
+// String returns the string representation of the CommandType.
+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
+
+const sidebarCompactModeBreakpoint = 120
+
+const (
+	SystemCommands CommandType = iota
+	UserCommands
+	MCPPrompts
+)
+
 // Commands represents a dialog that shows available commands.
 type Commands struct {
 	com    *common.Common
@@ -37,39 +45,33 @@ type Commands struct {
 		Next,
 		Previous,
 		Tab,
+		ShiftTab,
 		Close key.Binding
 	}
 
-	sessionID  string // can be empty for non-session-specific commands
-	selected   uicmd.CommandType
-	userCmds   []uicmd.Command
-	mcpPrompts *csync.Slice[uicmd.Command]
+	sessionID string // can be empty for non-session-specific commands
+	selected  CommandType
 
 	help  help.Model
 	input textinput.Model
 	list  *list.FilterableList
 
-	width int
+	windowWidth int
+
+	customCommands    []commands.CustomCommand
+	mcpCustomCommands []commands.MCPCustomCommand
 }
 
 var _ Dialog = (*Commands)(nil)
 
 // NewCommands creates a new commands dialog.
-func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
-	commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
-	if err != nil {
-		return nil, err
-	}
-
-	mcpPrompts := csync.NewSlice[uicmd.Command]()
-	mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
-
+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
 	c := &Commands{
-		com:        com,
-		userCmds:   commands,
-		selected:   uicmd.SystemCommands,
-		mcpPrompts: mcpPrompts,
-		sessionID:  sessionID,
+		com:               com,
+		selected:          SystemCommands,
+		sessionID:         sessionID,
+		customCommands:    customCommands,
+		mcpCustomCommands: mcpCustomCommands,
 	}
 
 	help := help.New()
@@ -96,7 +98,7 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 		key.WithHelp("↑/↓", "choose"),
 	)
 	c.keyMap.Next = key.NewBinding(
-		key.WithKeys("down", "ctrl+n"),
+		key.WithKeys("down"),
 		key.WithHelp("↓", "next item"),
 	)
 	c.keyMap.Previous = key.NewBinding(
@@ -107,12 +109,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 		key.WithKeys("tab"),
 		key.WithHelp("tab", "switch selection"),
 	)
+	c.keyMap.ShiftTab = key.NewBinding(
+		key.WithKeys("shift+tab"),
+		key.WithHelp("shift+tab", "switch selection prev"),
+	)
 	closeKey := CloseKey
 	closeKey.SetHelp("esc", "cancel")
 	c.keyMap.Close = closeKey
 
 	// Set initial commands
-	c.setCommandType(c.selected)
+	c.setCommandItems(c.selected)
 
 	return c, nil
 }
@@ -150,20 +156,28 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
 		case key.Matches(msg, c.keyMap.Select):
 			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
 				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
-					// TODO: Please unravel this mess later and the Command
-					// Handler design.
-					if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh??
-						return cmd()
-					}
+					return item.Action()
 				}
 			}
 		case key.Matches(msg, c.keyMap.Tab):
-			if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
+			if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
 				c.selected = c.nextCommandType()
-				c.setCommandType(c.selected)
+				c.setCommandItems(c.selected)
+			}
+		case key.Matches(msg, c.keyMap.ShiftTab):
+			if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
+				c.selected = c.previousCommandType()
+				c.setCommandItems(c.selected)
 			}
 		default:
 			var cmd tea.Cmd
+			for _, item := range c.list.VisibleItems() {
+				if item, ok := item.(*CommandItem); ok && item != nil {
+					if msg.String() == item.Shortcut() {
+						return item.Action()
+					}
+				}
+			}
 			c.input, cmd = c.input.Update(msg)
 			value := c.input.Value()
 			c.list.SetFilter(value)
@@ -175,28 +189,18 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
 	return nil
 }
 
-// ReloadMCPPrompts reloads the MCP prompts.
-func (c *Commands) ReloadMCPPrompts() tea.Cmd {
-	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
-	// If we're currently viewing MCP prompts, refresh the list
-	if c.selected == uicmd.MCPPrompts {
-		c.setCommandType(uicmd.MCPPrompts)
-	}
-	return nil
-}
-
 // Cursor returns the cursor position relative to the dialog.
 func (c *Commands) Cursor() *tea.Cursor {
 	return InputCursor(c.com.Styles, c.input.Cursor())
 }
 
 // commandsRadioView generates the command type selector radio buttons.
-func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
 	if !hasUserCmds && !hasMCPPrompts {
 		return ""
 	}
 
-	selectedFn := func(t uicmd.CommandType) string {
+	selectedFn := func(t CommandType) string {
 		if t == selected {
 			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
 		}
@@ -204,14 +208,14 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
 	}
 
 	parts := []string{
-		selectedFn(uicmd.SystemCommands),
+		selectedFn(SystemCommands),
 	}
 
 	if hasUserCmds {
-		parts = append(parts, selectedFn(uicmd.UserCommands))
+		parts = append(parts, selectedFn(UserCommands))
 	}
 	if hasMCPPrompts {
-		parts = append(parts, selectedFn(uicmd.MCPPrompts))
+		parts = append(parts, selectedFn(MCPPrompts))
 	}
 
 	return strings.Join(parts, " ")
@@ -222,7 +226,12 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	t := c.com.Styles
 	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
 	height := max(0, min(defaultDialogHeight, area.Dy()))
-	c.width = width
+	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
+		// since some items in the list depend on width (e.g. toggle sidebar command),
+		// we need to reset the command items when width changes
+		c.setCommandItems(c.selected)
+	}
+	c.windowWidth = area.Dx()
 	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -233,7 +242,7 @@ 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.userCmds) > 0, c.mcpPrompts.Len() > 0)
+	radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
 	titleStyle := t.Dialog.Title
 	dialogStyle := t.Dialog.View.Width(width)
 	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
@@ -265,99 +274,116 @@ func (c *Commands) FullHelp() [][]key.Binding {
 	}
 }
 
-func (c *Commands) nextCommandType() uicmd.CommandType {
+// nextCommandType returns the next command type in the cycle.
+func (c *Commands) nextCommandType() CommandType {
 	switch c.selected {
-	case uicmd.SystemCommands:
-		if len(c.userCmds) > 0 {
-			return uicmd.UserCommands
+	case SystemCommands:
+		if len(c.customCommands) > 0 {
+			return UserCommands
 		}
-		if c.mcpPrompts.Len() > 0 {
-			return uicmd.MCPPrompts
+		if len(c.mcpCustomCommands) > 0 {
+			return MCPPrompts
 		}
 		fallthrough
-	case uicmd.UserCommands:
-		if c.mcpPrompts.Len() > 0 {
-			return uicmd.MCPPrompts
+	case UserCommands:
+		if len(c.mcpCustomCommands) > 0 {
+			return MCPPrompts
 		}
 		fallthrough
-	case uicmd.MCPPrompts:
-		return uicmd.SystemCommands
+	case MCPPrompts:
+		return SystemCommands
 	default:
-		return uicmd.SystemCommands
+		return SystemCommands
 	}
 }
 
-func (c *Commands) setCommandType(commandType uicmd.CommandType) {
-	c.selected = commandType
-
-	var commands []uicmd.Command
+// previousCommandType returns the previous command type in the cycle.
+func (c *Commands) previousCommandType() CommandType {
 	switch c.selected {
-	case uicmd.SystemCommands:
-		commands = c.defaultCommands()
-	case uicmd.UserCommands:
-		commands = c.userCmds
-	case uicmd.MCPPrompts:
-		commands = slices.Collect(c.mcpPrompts.Seq())
+	case SystemCommands:
+		if len(c.mcpCustomCommands) > 0 {
+			return MCPPrompts
+		}
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	case UserCommands:
+		return SystemCommands
+	case MCPPrompts:
+		if len(c.customCommands) > 0 {
+			return UserCommands
+		}
+		return SystemCommands
+	default:
+		return SystemCommands
 	}
+}
+
+// setCommandItems sets the command items based on the specified command type.
+func (c *Commands) setCommandItems(commandType CommandType) {
+	c.selected = commandType
 
 	commandItems := []list.FilterableItem{}
-	for _, cmd := range commands {
-		commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
+	switch c.selected {
+	case SystemCommands:
+		for _, cmd := range c.defaultCommands() {
+			commandItems = append(commandItems, cmd)
+		}
+	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,
+				}
+			}
+			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,
+				}
+			}
+			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
+		}
 	}
 
 	c.list.SetItems(commandItems...)
-	c.list.SetSelected(0)
 	c.list.SetFilter("")
 	c.list.ScrollToTop()
 	c.list.SetSelected(0)
 	c.input.SetValue("")
 }
 
-// TODO: Rethink this
-func (c *Commands) defaultCommands() []uicmd.Command {
-	commands := []uicmd.Command{
-		{
-			ID:          "new_session",
-			Title:       "New Session",
-			Description: "start a new session",
-			Shortcut:    "ctrl+n",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionNewSession{})
-			},
-		},
-		{
-			ID:          "switch_session",
-			Title:       "Switch Session",
-			Description: "Switch to a different session",
-			Shortcut:    "ctrl+s",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
-			},
-		},
-		{
-			ID:          "switch_model",
-			Title:       "Switch Model",
-			Description: "Switch to a different model",
-			// FIXME: The shortcut might get updated if enhanced keyboard is supported.
-			Shortcut: "ctrl+l",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
-			},
-		},
+// defaultCommands returns the list of default system commands.
+func (c *Commands) defaultCommands() []*CommandItem {
+	commands := []*CommandItem{
+		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
+		NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
+		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
 	}
 
 	// Only show compact command if there's an active session
 	if c.sessionID != "" {
-		commands = append(commands, uicmd.Command{
-			ID:          "Summarize",
-			Title:       "Summarize Session",
-			Description: "Summarize the current session and create a new one with the summary",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionSummarize{
-					SessionID: c.sessionID,
-				})
-			},
-		})
+		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
 	}
 
 	// Add reasoning toggle for models that support it
@@ -374,116 +400,58 @@ func (c *Commands) defaultCommands() []uicmd.Command {
 				if selectedModel.Think {
 					status = "Disable"
 				}
-				commands = append(commands, uicmd.Command{
-					ID:          "toggle_thinking",
-					Title:       status + " Thinking Mode",
-					Description: "Toggle model thinking for reasoning-capable models",
-					Handler: func(cmd uicmd.Command) tea.Cmd {
-						return uiutil.CmdHandler(ActionToggleThinking{})
-					},
-				})
+				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
 			}
 
 			// OpenAI models: reasoning effort dialog
 			if len(model.ReasoningLevels) > 0 {
-				commands = append(commands, uicmd.Command{
-					ID:          "select_reasoning_effort",
-					Title:       "Select Reasoning Effort",
-					Description: "Choose reasoning effort level (low/medium/high)",
-					Handler: func(cmd uicmd.Command) tea.Cmd {
-						return uiutil.CmdHandler(ActionOpenDialog{
-							// TODO: Pass reasoning dialog id
-						})
-					},
-				})
+				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
+					// TODO: Pass in the reasoning effort dialog id
+				}))
 			}
 		}
 	}
-	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
-	// TODO: Get. Rid. Of. Magic. Numbers!
-	if c.width > 120 && c.sessionID != "" {
-		commands = append(commands, uicmd.Command{
-			ID:          "toggle_sidebar",
-			Title:       "Toggle Sidebar",
-			Description: "Toggle between compact and normal layout",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionToggleCompactMode{})
-			},
-		})
+	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
+	if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
+		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
 	}
 	if c.sessionID != "" {
 		cfg := c.com.Config()
 		agentCfg := cfg.Agents[config.AgentCoder]
 		model := cfg.GetModelByType(agentCfg.Model)
 		if model != nil && model.SupportsImages {
-			commands = append(commands, uicmd.Command{
-				ID:          "file_picker",
-				Title:       "Open File Picker",
-				Shortcut:    "ctrl+f",
-				Description: "Open file picker",
-				Handler: func(cmd uicmd.Command) tea.Cmd {
-					return uiutil.CmdHandler(ActionOpenDialog{
-						// TODO: Pass file picker dialog id
-					})
-				},
-			})
+			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
+				// TODO: Pass in the file picker dialog id
+			}))
 		}
 	}
 
 	// Add external editor command if $EDITOR is available
 	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
 	if os.Getenv("EDITOR") != "" {
-		commands = append(commands, uicmd.Command{
-			ID:          "open_external_editor",
-			Title:       "Open External Editor",
-			Shortcut:    "ctrl+o",
-			Description: "Open external editor to compose message",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionExternalEditor{})
-			},
-		})
+		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
 	}
 
-	return append(commands, []uicmd.Command{
-		{
-			ID:          "toggle_yolo",
-			Title:       "Toggle Yolo Mode",
-			Description: "Toggle yolo mode",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionToggleYoloMode{})
-			},
-		},
-		{
-			ID:          "toggle_help",
-			Title:       "Toggle Help",
-			Shortcut:    "ctrl+g",
-			Description: "Toggle help",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(ActionToggleHelp{})
-			},
-		},
-		{
-			ID:          "init",
-			Title:       "Initialize Project",
-			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				initPrompt, err := agent.InitializePrompt(*c.com.Config())
-				if err != nil {
-					return uiutil.ReportError(err)
-				}
-				return uiutil.CmdHandler(chat.SendMsg{
-					Text: initPrompt,
-				})
-			},
-		},
-		{
-			ID:          "quit",
-			Title:       "Quit",
-			Description: "Quit",
-			Shortcut:    "ctrl+c",
-			Handler: func(cmd uicmd.Command) tea.Cmd {
-				return uiutil.CmdHandler(tea.QuitMsg{})
-			},
-		},
-	}...)
+	return append(commands,
+		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
+		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
+		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
+		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
+	)
+}
+
+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
+	c.customCommands = customCommands
+	if c.selected == UserCommands {
+		c.setCommandItems(c.selected)
+	}
+}
+
+// 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
+	if c.selected == MCPPrompts {
+		c.setCommandItems(c.selected)
+	}
 }

internal/ui/dialog/commands_item.go 🔗

@@ -2,37 +2,42 @@ package dialog
 
 import (
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/crush/internal/uicmd"
 	"github.com/sahilm/fuzzy"
 )
 
 // CommandItem wraps a uicmd.Command to implement the ListItem interface.
 type CommandItem struct {
-	Cmd     uicmd.Command
-	t       *styles.Styles
-	m       fuzzy.Match
-	cache   map[int]string
-	focused bool
+	id       string
+	title    string
+	shortcut string
+	action   Action
+	t        *styles.Styles
+	m        fuzzy.Match
+	cache    map[int]string
+	focused  bool
 }
 
 var _ ListItem = &CommandItem{}
 
 // NewCommandItem creates a new CommandItem.
-func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem {
+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
 	return &CommandItem{
-		Cmd: cmd,
-		t:   t,
+		id:       id,
+		t:        t,
+		title:    title,
+		shortcut: shortcut,
+		action:   action,
 	}
 }
 
 // Filter implements ListItem.
 func (c *CommandItem) Filter() string {
-	return c.Cmd.Title
+	return c.title
 }
 
 // ID implements ListItem.
 func (c *CommandItem) ID() string {
-	return c.Cmd.ID
+	return c.id
 }
 
 // SetFocused implements ListItem.
@@ -49,7 +54,17 @@ func (c *CommandItem) SetMatch(m fuzzy.Match) {
 	c.m = m
 }
 
+// Action returns the action associated with the command item.
+func (c *CommandItem) Action() Action {
+	return c.action
+}
+
+// Shortcut returns the shortcut associated with the command item.
+func (c *CommandItem) Shortcut() string {
+	return c.shortcut
+}
+
 // Render implements ListItem.
 func (c *CommandItem) Render(width int) string {
-	return renderItem(c.t, c.Cmd.Title, c.Cmd.Shortcut, c.focused, width, c.cache, &c.m)
+	return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
 }

internal/ui/model/ui.go 🔗

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"image"
+	"log/slog"
 	"math/rand"
 	"net/http"
 	"os"
@@ -23,6 +24,7 @@ import (
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
@@ -76,7 +78,18 @@ type openEditorMsg struct {
 	Text string
 }
 
-type cancelTimerExpiredMsg struct{}
+type (
+	// cancelTimerExpiredMsg is sent when the cancel timer expires.
+	cancelTimerExpiredMsg struct{}
+	// userCommandsLoadedMsg is sent when user commands are loaded.
+	userCommandsLoadedMsg struct {
+		Commands []commands.CustomCommand
+	}
+	// mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
+	mcpCustomCommandsLoadedMsg struct {
+		Prompts []commands.MCPCustomCommand
+	}
+)
 
 // UI represents the main user interface model.
 type UI struct {
@@ -144,6 +157,10 @@ type UI struct {
 
 	// sidebarLogo keeps a cached version of the sidebar sidebarLogo.
 	sidebarLogo string
+
+	// custom commands & mcp commands
+	customCommands    []commands.CustomCommand
+	mcpCustomCommands []commands.MCPCustomCommand
 }
 
 // New creates a new instance of the [UI] model.
@@ -225,9 +242,37 @@ func (m *UI) Init() tea.Cmd {
 	if m.QueryVersion {
 		cmds = append(cmds, tea.RequestTerminalVersion)
 	}
+	// load the user commands async
+	cmds = append(cmds, m.loadCustomCommands())
 	return tea.Batch(cmds...)
 }
 
+// loadCustomCommands loads the custom commands asynchronously.
+func (m *UI) loadCustomCommands() tea.Cmd {
+	return func() tea.Msg {
+		customCommands, err := commands.LoadCustomCommands(m.com.Config())
+		if err != nil {
+			slog.Error("failed to load custom commands", "error", err)
+		}
+		return userCommandsLoadedMsg{Commands: customCommands}
+	}
+}
+
+// loadMCPrompts loads the MCP prompts asynchronously.
+func (m *UI) loadMCPrompts() tea.Cmd {
+	return func() tea.Msg {
+		prompts, err := commands.LoadMCPCustomCommands()
+		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{}
+		}
+		return mcpCustomCommandsLoadedMsg{Prompts: prompts}
+	}
+}
+
 // Update handles updates to the UI model.
 func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	var cmds []tea.Cmd
@@ -250,6 +295,29 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 
+	case userCommandsLoadedMsg:
+		m.customCommands = msg.Commands
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetCustomCommands(m.customCommands)
+		}
+	case mcpCustomCommandsLoadedMsg:
+		m.mcpCustomCommands = msg.Prompts
+		dia := m.dialog.Dialog(dialog.CommandsID)
+		if dia == nil {
+			break
+		}
+
+		commands, ok := dia.(*dialog.Commands)
+		if ok {
+			commands.SetMCPCustomCommands(m.mcpCustomCommands)
+		}
+
 	case pubsub.Event[message.Message]:
 		// Check if this is a child session message for an agent tool.
 		if m.session == nil {
@@ -274,18 +342,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		m.lspStates = app.GetLSPStates()
 	case pubsub.Event[mcp.Event]:
 		m.mcpStates = mcp.GetStates()
-		if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
-			dia := m.dialog.Dialog(dialog.CommandsID)
-			if dia == nil {
+		// check if all mcps are initialized
+		initialized := true
+		for _, state := range m.mcpStates {
+			if state.State == mcp.StateStarting {
+				initialized = false
 				break
 			}
-
-			commands, ok := dia.(*dialog.Commands)
-			if ok {
-				if cmd := commands.ReloadMCPPrompts(); cmd != nil {
-					cmds = append(cmds, cmd)
-				}
-			}
+		}
+		if initialized && m.mcpCustomCommands == nil {
+			cmds = append(cmds, m.loadMCPrompts())
 		}
 	case pubsub.Event[permission.PermissionRequest]:
 		if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -776,6 +842,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
 		m.dialog.CloseDialog(dialog.CommandsID)
 	case dialog.ActionQuit:
 		cmds = append(cmds, tea.Quit)
+	case dialog.ActionInitializeProject:
+		if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
+			break
+		}
+		cmds = append(cmds, m.initializeProject())
+
 	case dialog.ActionSelectModel:
 		if m.com.App.AgentCoordinator.IsBusy() {
 			cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
@@ -2001,7 +2074,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
 		sessionID = m.session.ID
 	}
 
-	commands, err := dialog.NewCommands(m.com, sessionID)
+	commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
 	if err != nil {
 		return uiutil.ReportError(err)
 	}

internal/uicmd/uicmd.go 🔗

@@ -1,6 +1,7 @@
 // Package uicmd provides functionality to load and handle custom commands
 // from markdown files and MCP prompts.
 // TODO: Move this into internal/ui after refactoring.
+// TODO: DELETE when we delete the old tui
 package uicmd
 
 import (