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