| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477 |
- package commands
- import (
- "os"
- "slices"
- "strings"
- "github.com/charmbracelet/bubbles/v2/help"
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/catwalk/pkg/catwalk"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools/mcp"
- "github.com/charmbracelet/crush/internal/config"
- "github.com/charmbracelet/crush/internal/csync"
- "github.com/charmbracelet/crush/internal/pubsub"
- "github.com/charmbracelet/crush/internal/tui/components/chat"
- "github.com/charmbracelet/crush/internal/tui/components/core"
- "github.com/charmbracelet/crush/internal/tui/components/dialogs"
- "github.com/charmbracelet/crush/internal/tui/exp/list"
- "github.com/charmbracelet/crush/internal/tui/styles"
- "github.com/charmbracelet/crush/internal/tui/util"
- )
- const (
- CommandsDialogID dialogs.DialogID = "commands"
- defaultWidth int = 70
- )
- type commandType uint
- func (c commandType) String() string { return []string{"System", "User", "MCP"}[c] }
- const (
- SystemCommands commandType = iota
- UserCommands
- MCPPrompts
- )
- type listModel = list.FilterableList[list.CompletionItem[Command]]
- // Command represents a command that can be executed
- type Command struct {
- ID string
- Title string
- Description string
- Shortcut string // Optional shortcut for the command
- Handler func(cmd Command) tea.Cmd
- }
- // CommandsDialog represents the commands dialog.
- type CommandsDialog interface {
- dialogs.DialogModel
- }
- type commandDialogCmp struct {
- width int
- wWidth int // Width of the terminal window
- wHeight int // Height of the terminal window
- commandList listModel
- keyMap CommandsDialogKeyMap
- help help.Model
- selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts
- userCommands []Command // User-defined commands
- mcpPrompts *csync.Slice[Command] // MCP prompts
- sessionID string // Current session ID
- }
- type (
- SwitchSessionsMsg struct{}
- NewSessionsMsg struct{}
- SwitchModelMsg struct{}
- QuitMsg struct{}
- OpenFilePickerMsg struct{}
- ToggleHelpMsg struct{}
- ToggleCompactModeMsg struct{}
- ToggleThinkingMsg struct{}
- OpenReasoningDialogMsg struct{}
- OpenExternalEditorMsg struct{}
- ToggleYoloModeMsg struct{}
- CompactMsg struct {
- SessionID string
- }
- )
- func NewCommandDialog(sessionID string) CommandsDialog {
- keyMap := DefaultCommandsDialogKeyMap()
- listKeyMap := list.DefaultKeyMap()
- listKeyMap.Down.SetEnabled(false)
- listKeyMap.Up.SetEnabled(false)
- listKeyMap.DownOneItem = keyMap.Next
- listKeyMap.UpOneItem = keyMap.Previous
- t := styles.CurrentTheme()
- inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
- commandList := list.NewFilterableList(
- []list.CompletionItem[Command]{},
- list.WithFilterInputStyle(inputStyle),
- list.WithFilterListOptions(
- list.WithKeyMap(listKeyMap),
- list.WithWrapNavigation(),
- list.WithResizeByList(),
- ),
- )
- help := help.New()
- help.Styles = t.S().Help
- return &commandDialogCmp{
- commandList: commandList,
- width: defaultWidth,
- keyMap: DefaultCommandsDialogKeyMap(),
- help: help,
- selected: SystemCommands,
- sessionID: sessionID,
- mcpPrompts: csync.NewSlice[Command](),
- }
- }
- func (c *commandDialogCmp) Init() tea.Cmd {
- commands, err := LoadCustomCommands()
- if err != nil {
- return util.ReportError(err)
- }
- c.userCommands = commands
- c.mcpPrompts.SetSlice(loadMCPPrompts())
- return c.setCommandType(c.selected)
- }
- func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- c.wWidth = msg.Width
- c.wHeight = msg.Height
- return c, tea.Batch(
- c.setCommandType(c.selected),
- c.commandList.SetSize(c.listWidth(), c.listHeight()),
- )
- case pubsub.Event[mcp.Event]:
- // Reload MCP prompts when MCP state changes
- if msg.Type == pubsub.UpdatedEvent {
- c.mcpPrompts.SetSlice(loadMCPPrompts())
- // If we're currently viewing MCP prompts, refresh the list
- if c.selected == MCPPrompts {
- return c, c.setCommandType(MCPPrompts)
- }
- return c, nil
- }
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, c.keyMap.Select):
- selectedItem := c.commandList.SelectedItem()
- if selectedItem == nil {
- return c, nil // No item selected, do nothing
- }
- command := (*selectedItem).Value()
- return c, tea.Sequence(
- util.CmdHandler(dialogs.CloseDialogMsg{}),
- command.Handler(command),
- )
- case key.Matches(msg, c.keyMap.Tab):
- if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
- return c, nil
- }
- return c, c.setCommandType(c.next())
- case key.Matches(msg, c.keyMap.Close):
- return c, util.CmdHandler(dialogs.CloseDialogMsg{})
- default:
- u, cmd := c.commandList.Update(msg)
- c.commandList = u.(listModel)
- return c, cmd
- }
- }
- return c, nil
- }
- func (c *commandDialogCmp) next() commandType {
- switch c.selected {
- case SystemCommands:
- if len(c.userCommands) > 0 {
- return UserCommands
- }
- if c.mcpPrompts.Len() > 0 {
- return MCPPrompts
- }
- fallthrough
- case UserCommands:
- if c.mcpPrompts.Len() > 0 {
- return MCPPrompts
- }
- fallthrough
- case MCPPrompts:
- return SystemCommands
- default:
- return SystemCommands
- }
- }
- func (c *commandDialogCmp) View() string {
- t := styles.CurrentTheme()
- listView := c.commandList
- radio := c.commandTypeRadio()
- header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
- if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
- header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
- }
- content := lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- listView.View(),
- "",
- t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
- )
- return c.style().Render(content)
- }
- func (c *commandDialogCmp) Cursor() *tea.Cursor {
- if cursor, ok := c.commandList.(util.Cursor); ok {
- cursor := cursor.Cursor()
- if cursor != nil {
- cursor = c.moveCursor(cursor)
- }
- return cursor
- }
- return nil
- }
- func (c *commandDialogCmp) commandTypeRadio() string {
- t := styles.CurrentTheme()
- fn := func(i commandType) string {
- if i == c.selected {
- return "◉ " + i.String()
- }
- return "○ " + i.String()
- }
- parts := []string{
- fn(SystemCommands),
- }
- if len(c.userCommands) > 0 {
- parts = append(parts, fn(UserCommands))
- }
- if c.mcpPrompts.Len() > 0 {
- parts = append(parts, fn(MCPPrompts))
- }
- return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
- }
- func (c *commandDialogCmp) listWidth() int {
- return defaultWidth - 2 // 4 for padding
- }
- func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
- c.selected = commandType
- var commands []Command
- switch c.selected {
- case SystemCommands:
- commands = c.defaultCommands()
- case UserCommands:
- commands = c.userCommands
- case MCPPrompts:
- commands = slices.Collect(c.mcpPrompts.Seq())
- }
- commandItems := []list.CompletionItem[Command]{}
- for _, cmd := range commands {
- opts := []list.CompletionItemOption{
- list.WithCompletionID(cmd.ID),
- }
- if cmd.Shortcut != "" {
- opts = append(
- opts,
- list.WithCompletionShortcut(cmd.Shortcut),
- )
- }
- commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
- }
- return c.commandList.SetItems(commandItems)
- }
- func (c *commandDialogCmp) listHeight() int {
- listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
- return min(listHeigh, c.wHeight/2)
- }
- func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
- row, col := c.Position()
- offset := row + 3
- cursor.Y += offset
- cursor.X = cursor.X + col + 2
- return cursor
- }
- func (c *commandDialogCmp) style() lipgloss.Style {
- t := styles.CurrentTheme()
- return t.S().Base.
- Width(c.width).
- Border(lipgloss.RoundedBorder()).
- BorderForeground(t.BorderFocus)
- }
- func (c *commandDialogCmp) Position() (int, int) {
- row := c.wHeight/4 - 2 // just a bit above the center
- col := c.wWidth / 2
- col -= c.width / 2
- return row, col
- }
- func (c *commandDialogCmp) defaultCommands() []Command {
- commands := []Command{
- {
- ID: "new_session",
- Title: "New Session",
- Description: "start a new session",
- Shortcut: "ctrl+n",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(NewSessionsMsg{})
- },
- },
- {
- ID: "switch_session",
- Title: "Switch Session",
- Description: "Switch to a different session",
- Shortcut: "ctrl+s",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(SwitchSessionsMsg{})
- },
- },
- {
- ID: "switch_model",
- Title: "Switch Model",
- Description: "Switch to a different model",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(SwitchModelMsg{})
- },
- },
- }
- // Only show compact command if there's an active session
- if c.sessionID != "" {
- commands = append(commands, Command{
- ID: "Summarize",
- Title: "Summarize Session",
- Description: "Summarize the current session and create a new one with the summary",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(CompactMsg{
- SessionID: c.sessionID,
- })
- },
- })
- }
- // Add reasoning toggle for models that support it
- cfg := config.Get()
- if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
- providerCfg := cfg.GetProviderForModel(agentCfg.Model)
- model := cfg.GetModelByType(agentCfg.Model)
- if providerCfg != nil && model != nil && model.CanReason {
- selectedModel := cfg.Models[agentCfg.Model]
- // Anthropic models: thinking toggle
- if providerCfg.Type == catwalk.TypeAnthropic {
- status := "Enable"
- if selectedModel.Think {
- status = "Disable"
- }
- commands = append(commands, Command{
- ID: "toggle_thinking",
- Title: status + " Thinking Mode",
- Description: "Toggle model thinking for reasoning-capable models",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleThinkingMsg{})
- },
- })
- }
- // OpenAI models: reasoning effort dialog
- if len(model.ReasoningLevels) > 0 {
- commands = append(commands, Command{
- ID: "select_reasoning_effort",
- Title: "Select Reasoning Effort",
- Description: "Choose reasoning effort level (low/medium/high)",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(OpenReasoningDialogMsg{})
- },
- })
- }
- }
- }
- // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
- if c.wWidth > 120 && c.sessionID != "" {
- commands = append(commands, Command{
- ID: "toggle_sidebar",
- Title: "Toggle Sidebar",
- Description: "Toggle between compact and normal layout",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleCompactModeMsg{})
- },
- })
- }
- if c.sessionID != "" {
- agentCfg := config.Get().Agents[config.AgentCoder]
- model := config.Get().GetModelByType(agentCfg.Model)
- if model.SupportsImages {
- commands = append(commands, Command{
- ID: "file_picker",
- Title: "Open File Picker",
- Shortcut: "ctrl+f",
- Description: "Open file picker",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(OpenFilePickerMsg{})
- },
- })
- }
- }
- // Add external editor command if $EDITOR is available
- if os.Getenv("EDITOR") != "" {
- commands = append(commands, Command{
- ID: "open_external_editor",
- Title: "Open External Editor",
- Shortcut: "ctrl+o",
- Description: "Open external editor to compose message",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(OpenExternalEditorMsg{})
- },
- })
- }
- return append(commands, []Command{
- {
- ID: "toggle_yolo",
- Title: "Toggle Yolo Mode",
- Description: "Toggle yolo mode",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleYoloModeMsg{})
- },
- },
- {
- ID: "toggle_help",
- Title: "Toggle Help",
- Shortcut: "ctrl+g",
- Description: "Toggle help",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(ToggleHelpMsg{})
- },
- },
- {
- ID: "init",
- Title: "Initialize Project",
- Description: "Create/Update the CRUSH.md memory file",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(chat.SendMsg{
- Text: agent.InitializePrompt(),
- })
- },
- },
- {
- ID: "quit",
- Title: "Quit",
- Description: "Quit",
- Shortcut: "ctrl+c",
- Handler: func(cmd Command) tea.Cmd {
- return util.CmdHandler(QuitMsg{})
- },
- },
- }...)
- }
- func (c *commandDialogCmp) ID() dialogs.DialogID {
- return CommandsDialogID
- }
|