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