commands.go

  1package commands
  2
  3import (
  4	"fmt"
  5	"os"
  6	"slices"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/help"
 10	"charm.land/bubbles/v2/key"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13
 14	"github.com/charmbracelet/crush/internal/agent"
 15	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 16	"github.com/charmbracelet/crush/internal/config"
 17	"github.com/charmbracelet/crush/internal/csync"
 18	"github.com/charmbracelet/crush/internal/pubsub"
 19	"github.com/charmbracelet/crush/internal/tui/components/chat"
 20	"github.com/charmbracelet/crush/internal/tui/components/core"
 21	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 22	"github.com/charmbracelet/crush/internal/tui/exp/list"
 23	"github.com/charmbracelet/crush/internal/tui/styles"
 24	"github.com/charmbracelet/crush/internal/tui/util"
 25	"github.com/charmbracelet/crush/internal/uicmd"
 26)
 27
 28const (
 29	CommandsDialogID dialogs.DialogID = "commands"
 30
 31	defaultWidth int = 70
 32)
 33
 34type commandType = uicmd.CommandType
 35
 36const (
 37	SystemCommands = uicmd.SystemCommands
 38	UserCommands   = uicmd.UserCommands
 39	MCPPrompts     = uicmd.MCPPrompts
 40)
 41
 42type listModel = list.FilterableList[list.CompletionItem[Command]]
 43
 44// Command represents a command that can be executed
 45type (
 46	Command                         = uicmd.Command
 47	CommandRunCustomMsg             = uicmd.CommandRunCustomMsg
 48	ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
 49)
 50
 51// CommandsDialog represents the commands dialog.
 52type CommandsDialog interface {
 53	dialogs.DialogModel
 54}
 55
 56type commandDialogCmp struct {
 57	width   int
 58	wWidth  int // Width of the terminal window
 59	wHeight int // Height of the terminal window
 60
 61	commandList  listModel
 62	keyMap       CommandsDialogKeyMap
 63	help         help.Model
 64	selected     commandType           // Selected SystemCommands, UserCommands, or MCPPrompts
 65	userCommands []Command             // User-defined commands
 66	mcpPrompts   *csync.Slice[Command] // MCP prompts
 67	sessionID    string                // Current session ID
 68}
 69
 70type (
 71	SwitchSessionsMsg      struct{}
 72	NewSessionsMsg         struct{}
 73	SwitchModelMsg         struct{}
 74	QuitMsg                struct{}
 75	OpenFilePickerMsg      struct{}
 76	ToggleHelpMsg          struct{}
 77	ToggleCompactModeMsg   struct{}
 78	ToggleThinkingMsg      struct{}
 79	OpenReasoningDialogMsg struct{}
 80	OpenExternalEditorMsg  struct{}
 81	ToggleYoloModeMsg      struct{}
 82	CompactMsg             struct {
 83		SessionID string
 84	}
 85)
 86
 87func NewCommandDialog(sessionID string) CommandsDialog {
 88	keyMap := DefaultCommandsDialogKeyMap()
 89	listKeyMap := list.DefaultKeyMap()
 90	listKeyMap.Down.SetEnabled(false)
 91	listKeyMap.Up.SetEnabled(false)
 92	listKeyMap.DownOneItem = keyMap.Next
 93	listKeyMap.UpOneItem = keyMap.Previous
 94
 95	t := styles.CurrentTheme()
 96	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 97	commandList := list.NewFilterableList(
 98		[]list.CompletionItem[Command]{},
 99		list.WithFilterInputStyle(inputStyle),
100		list.WithFilterListOptions(
101			list.WithKeyMap(listKeyMap),
102			list.WithWrapNavigation(),
103			list.WithResizeByList(),
104		),
105	)
106	help := help.New()
107	help.Styles = t.S().Help
108	return &commandDialogCmp{
109		commandList: commandList,
110		width:       defaultWidth,
111		keyMap:      DefaultCommandsDialogKeyMap(),
112		help:        help,
113		selected:    SystemCommands,
114		sessionID:   sessionID,
115		mcpPrompts:  csync.NewSlice[Command](),
116	}
117}
118
119func (c *commandDialogCmp) Init() tea.Cmd {
120	commands, err := uicmd.LoadCustomCommands()
121	if err != nil {
122		return util.ReportError(err)
123	}
124	c.userCommands = commands
125	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
126	return c.setCommandType(c.selected)
127}
128
129func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
130	switch msg := msg.(type) {
131	case tea.WindowSizeMsg:
132		c.wWidth = msg.Width
133		c.wHeight = msg.Height
134		return c, tea.Batch(
135			c.setCommandType(c.selected),
136			c.commandList.SetSize(c.listWidth(), c.listHeight()),
137		)
138	case pubsub.Event[mcp.Event]:
139		// Reload MCP prompts when MCP state changes
140		if msg.Type == pubsub.UpdatedEvent {
141			c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
142			// If we're currently viewing MCP prompts, refresh the list
143			if c.selected == MCPPrompts {
144				return c, c.setCommandType(MCPPrompts)
145			}
146			return c, nil
147		}
148	case tea.KeyPressMsg:
149		switch {
150		case key.Matches(msg, c.keyMap.Select):
151			selectedItem := c.commandList.SelectedItem()
152			if selectedItem == nil {
153				return c, nil // No item selected, do nothing
154			}
155			command := (*selectedItem).Value()
156			return c, tea.Sequence(
157				util.CmdHandler(dialogs.CloseDialogMsg{}),
158				command.Handler(command),
159			)
160		case key.Matches(msg, c.keyMap.Tab):
161			if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
162				return c, nil
163			}
164			return c, c.setCommandType(c.next())
165		case key.Matches(msg, c.keyMap.Close):
166			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
167		default:
168			u, cmd := c.commandList.Update(msg)
169			c.commandList = u.(listModel)
170			return c, cmd
171		}
172	}
173	return c, nil
174}
175
176func (c *commandDialogCmp) next() commandType {
177	switch c.selected {
178	case SystemCommands:
179		if len(c.userCommands) > 0 {
180			return UserCommands
181		}
182		if c.mcpPrompts.Len() > 0 {
183			return MCPPrompts
184		}
185		fallthrough
186	case UserCommands:
187		if c.mcpPrompts.Len() > 0 {
188			return MCPPrompts
189		}
190		fallthrough
191	case MCPPrompts:
192		return SystemCommands
193	default:
194		return SystemCommands
195	}
196}
197
198func (c *commandDialogCmp) View() string {
199	t := styles.CurrentTheme()
200	listView := c.commandList
201	radio := c.commandTypeRadio()
202
203	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
204	if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
205		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
206	}
207	content := lipgloss.JoinVertical(
208		lipgloss.Left,
209		header,
210		listView.View(),
211		"",
212		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
213	)
214	return c.style().Render(content)
215}
216
217func (c *commandDialogCmp) Cursor() *tea.Cursor {
218	if cursor, ok := c.commandList.(util.Cursor); ok {
219		cursor := cursor.Cursor()
220		if cursor != nil {
221			cursor = c.moveCursor(cursor)
222		}
223		return cursor
224	}
225	return nil
226}
227
228func (c *commandDialogCmp) commandTypeRadio() string {
229	t := styles.CurrentTheme()
230
231	fn := func(i commandType) string {
232		if i == c.selected {
233			return "◉ " + i.String()
234		}
235		return "○ " + i.String()
236	}
237
238	parts := []string{
239		fn(SystemCommands),
240	}
241	if len(c.userCommands) > 0 {
242		parts = append(parts, fn(UserCommands))
243	}
244	if c.mcpPrompts.Len() > 0 {
245		parts = append(parts, fn(MCPPrompts))
246	}
247	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
248}
249
250func (c *commandDialogCmp) listWidth() int {
251	return defaultWidth - 2 // 4 for padding
252}
253
254func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
255	c.selected = commandType
256
257	var commands []Command
258	switch c.selected {
259	case SystemCommands:
260		commands = c.defaultCommands()
261	case UserCommands:
262		commands = c.userCommands
263	case MCPPrompts:
264		commands = slices.Collect(c.mcpPrompts.Seq())
265	}
266
267	commandItems := []list.CompletionItem[Command]{}
268	for _, cmd := range commands {
269		opts := []list.CompletionItemOption{
270			list.WithCompletionID(cmd.ID),
271		}
272		if cmd.Shortcut != "" {
273			opts = append(
274				opts,
275				list.WithCompletionShortcut(cmd.Shortcut),
276			)
277		}
278		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
279	}
280	return c.commandList.SetItems(commandItems)
281}
282
283func (c *commandDialogCmp) listHeight() int {
284	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
285	return min(listHeigh, c.wHeight/2)
286}
287
288func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
289	row, col := c.Position()
290	offset := row + 3
291	cursor.Y += offset
292	cursor.X = cursor.X + col + 2
293	return cursor
294}
295
296func (c *commandDialogCmp) style() lipgloss.Style {
297	t := styles.CurrentTheme()
298	return t.S().Base.
299		Width(c.width).
300		Border(lipgloss.RoundedBorder()).
301		BorderForeground(t.BorderFocus)
302}
303
304func (c *commandDialogCmp) Position() (int, int) {
305	row := c.wHeight/4 - 2 // just a bit above the center
306	col := c.wWidth / 2
307	col -= c.width / 2
308	return row, col
309}
310
311func (c *commandDialogCmp) defaultCommands() []Command {
312	commands := []Command{
313		{
314			ID:          "new_session",
315			Title:       "New Session",
316			Description: "start a new session",
317			Shortcut:    "ctrl+n",
318			Handler: func(cmd Command) tea.Cmd {
319				return util.CmdHandler(NewSessionsMsg{})
320			},
321		},
322		{
323			ID:          "switch_session",
324			Title:       "Switch Session",
325			Description: "Switch to a different session",
326			Shortcut:    "ctrl+s",
327			Handler: func(cmd Command) tea.Cmd {
328				return util.CmdHandler(SwitchSessionsMsg{})
329			},
330		},
331		{
332			ID:          "switch_model",
333			Title:       "Switch Model",
334			Description: "Switch to a different model",
335			Shortcut:    "ctrl+l",
336			Handler: func(cmd Command) tea.Cmd {
337				return util.CmdHandler(SwitchModelMsg{})
338			},
339		},
340	}
341
342	// Only show compact command if there's an active session
343	if c.sessionID != "" {
344		commands = append(commands, Command{
345			ID:          "Summarize",
346			Title:       "Summarize Session",
347			Description: "Summarize the current session and create a new one with the summary",
348			Handler: func(cmd Command) tea.Cmd {
349				return util.CmdHandler(CompactMsg{
350					SessionID: c.sessionID,
351				})
352			},
353		})
354	}
355
356	// Add reasoning toggle for models that support it
357	cfg := config.Get()
358	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
359		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
360		model := cfg.GetModelByType(agentCfg.Model)
361		if providerCfg != nil && model != nil && model.CanReason {
362			selectedModel := cfg.Models[agentCfg.Model]
363
364			// Anthropic-style thinking toggle: Anthropic or Hyper only.
365			if model.CanReason && len(model.ReasoningLevels) == 0 {
366				status := "Enable"
367				if selectedModel.Think {
368					status = "Disable"
369				}
370				commands = append(commands, Command{
371					ID:          "toggle_thinking",
372					Title:       status + " Thinking Mode",
373					Description: "Toggle model thinking for reasoning-capable models",
374					Handler: func(cmd Command) tea.Cmd {
375						return util.CmdHandler(ToggleThinkingMsg{})
376					},
377				})
378			}
379
380			// OpenAI models: reasoning effort dialog
381			if len(model.ReasoningLevels) > 0 {
382				commands = append(commands, Command{
383					ID:          "select_reasoning_effort",
384					Title:       "Select Reasoning Effort",
385					Description: "Choose reasoning effort level (low/medium/high)",
386					Handler: func(cmd Command) tea.Cmd {
387						return util.CmdHandler(OpenReasoningDialogMsg{})
388					},
389				})
390			}
391		}
392	}
393	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
394	if c.wWidth > 120 && c.sessionID != "" {
395		commands = append(commands, Command{
396			ID:          "toggle_sidebar",
397			Title:       "Toggle Sidebar",
398			Description: "Toggle between compact and normal layout",
399			Handler: func(cmd Command) tea.Cmd {
400				return util.CmdHandler(ToggleCompactModeMsg{})
401			},
402		})
403	}
404	if c.sessionID != "" {
405		agentCfg := config.Get().Agents[config.AgentCoder]
406		model := config.Get().GetModelByType(agentCfg.Model)
407		if model.SupportsImages {
408			commands = append(commands, Command{
409				ID:          "file_picker",
410				Title:       "Open File Picker",
411				Shortcut:    "ctrl+f",
412				Description: "Open file picker",
413				Handler: func(cmd Command) tea.Cmd {
414					return util.CmdHandler(OpenFilePickerMsg{})
415				},
416			})
417		}
418	}
419
420	// Add external editor command if $EDITOR is available
421	if os.Getenv("EDITOR") != "" {
422		commands = append(commands, Command{
423			ID:          "open_external_editor",
424			Title:       "Open External Editor",
425			Shortcut:    "ctrl+o",
426			Description: "Open external editor to compose message",
427			Handler: func(cmd Command) tea.Cmd {
428				return util.CmdHandler(OpenExternalEditorMsg{})
429			},
430		})
431	}
432
433	return append(commands, []Command{
434		{
435			ID:          "toggle_yolo",
436			Title:       "Toggle Yolo Mode",
437			Description: "Toggle yolo mode",
438			Handler: func(cmd Command) tea.Cmd {
439				return util.CmdHandler(ToggleYoloModeMsg{})
440			},
441		},
442		{
443			ID:          "toggle_help",
444			Title:       "Toggle Help",
445			Shortcut:    "ctrl+g",
446			Description: "Toggle help",
447			Handler: func(cmd Command) tea.Cmd {
448				return util.CmdHandler(ToggleHelpMsg{})
449			},
450		},
451		{
452			ID:          "init",
453			Title:       "Initialize Project",
454			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
455			Handler: func(cmd Command) tea.Cmd {
456				initPrompt, err := agent.InitializePrompt(*config.Get())
457				if err != nil {
458					return util.ReportError(err)
459				}
460				return util.CmdHandler(chat.SendMsg{
461					Text: initPrompt,
462				})
463			},
464		},
465		{
466			ID:          "quit",
467			Title:       "Quit",
468			Description: "Quit",
469			Shortcut:    "ctrl+c",
470			Handler: func(cmd Command) tea.Cmd {
471				return util.CmdHandler(QuitMsg{})
472			},
473		},
474	}...)
475}
476
477func (c *commandDialogCmp) ID() dialogs.DialogID {
478	return CommandsDialogID
479}