commands.go

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