From 7a2769c0a3b51fb0bfe6988c43208eb272f03d67 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 13 Jan 2026 16:26:26 +0100 Subject: [PATCH] Refactor commands (#1847) --- 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(-) create mode 100644 internal/commands/commands.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go new file mode 100644 index 0000000000000000000000000000000000000000..169b789abd224b774592032c02a5156b91efb3a5 --- /dev/null +++ b/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") +} diff --git a/internal/ui/dialog/actions.go b/internal/ui/dialog/actions.go index ecf81432410c31a523d221221e00c50d9862b9ac..2fe0513ec56bc70ed3ec5bbe1eb9dde365408cdf 100644 --- a/internal/ui/dialog/actions.go +++ b/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. diff --git a/internal/ui/dialog/commands.go b/internal/ui/dialog/commands.go index 8211016b95fb1e71b5cb64699d2d0fd12930ee84..ae9574e2d76b74ccc7465b59a2cafce6f7d9fd0e 100644 --- a/internal/ui/dialog/commands.go +++ b/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) + } } diff --git a/internal/ui/dialog/commands_item.go b/internal/ui/dialog/commands_item.go index 408fe70865bfb02ce446c57c32c2b3d79bfd8fe5..9a2cf2ceef2be54c6f8d9897d4ddd923fd07b80f 100644 --- a/internal/ui/dialog/commands_item.go +++ b/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) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 620636ad717100db01e3524c500147f7bd8576ee..23c7f0d7b8811aa598eb71a0c42a73a2b17e76cf 100644 --- a/internal/ui/model/ui.go +++ b/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) } diff --git a/internal/uicmd/uicmd.go b/internal/uicmd/uicmd.go index c571dacd1989c518347e3a773b36d6d5fd2b8878..c2ce2d89d1457459ac84c9e97c6e68b371e042d8 100644 --- a/internal/uicmd/uicmd.go +++ b/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 (