commands.go

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