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