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