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			switch c.selected {
192			case SystemCommands:
193				if len(c.userCommands) > 0 {
194					return c, c.SetCommandType(UserCommands)
195				}
196				if len(c.mcpPrompts) > 0 {
197					return c, c.SetCommandType(MCPPrompts)
198				}
199				return c, nil
200			case UserCommands:
201				if len(c.mcpPrompts) > 0 {
202					return c, c.SetCommandType(MCPPrompts)
203				}
204				return c, c.SetCommandType(SystemCommands)
205			case MCPPrompts:
206				return c, c.SetCommandType(SystemCommands)
207			}
208		case key.Matches(msg, c.keyMap.Close):
209			if c.cancel != nil {
210				c.cancel()
211			}
212			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
213		default:
214			u, cmd := c.commandList.Update(msg)
215			c.commandList = u.(listModel)
216			return c, cmd
217		}
218	}
219	return c, nil
220}
221
222func (c *commandDialogCmp) View() string {
223	t := styles.CurrentTheme()
224	listView := c.commandList
225	radio := c.commandTypeRadio()
226
227	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
228	if len(c.userCommands) == 0 && len(c.mcpPrompts) == 0 {
229		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
230	}
231	content := lipgloss.JoinVertical(
232		lipgloss.Left,
233		header,
234		listView.View(),
235		"",
236		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
237	)
238	return c.style().Render(content)
239}
240
241func (c *commandDialogCmp) Cursor() *tea.Cursor {
242	if cursor, ok := c.commandList.(util.Cursor); ok {
243		cursor := cursor.Cursor()
244		if cursor != nil {
245			cursor = c.moveCursor(cursor)
246		}
247		return cursor
248	}
249	return nil
250}
251
252func (c *commandDialogCmp) commandTypeRadio() string {
253	t := styles.CurrentTheme()
254
255	fn := func(i CommandType) string {
256		if i == c.selected {
257			return "◉ " + i.String()
258		}
259		return "○ " + i.String()
260	}
261
262	parts := []string{
263		fn(SystemCommands),
264	}
265	if len(c.userCommands) > 0 {
266		parts = append(parts, fn(UserCommands))
267	}
268	if len(c.mcpPrompts) > 0 {
269		parts = append(parts, fn(MCPPrompts))
270	}
271	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
272}
273
274func (c *commandDialogCmp) listWidth() int {
275	return defaultWidth - 2 // 4 for padding
276}
277
278func (c *commandDialogCmp) SetCommandType(commandType CommandType) tea.Cmd {
279	c.selected = commandType
280
281	var commands []Command
282	switch c.selected {
283	case SystemCommands:
284		commands = c.defaultCommands()
285	case UserCommands:
286		commands = c.userCommands
287	case MCPPrompts:
288		commands = c.mcpPrompts
289	}
290
291	commandItems := []list.CompletionItem[Command]{}
292	for _, cmd := range commands {
293		opts := []list.CompletionItemOption{
294			list.WithCompletionID(cmd.ID),
295		}
296		if cmd.Shortcut != "" {
297			opts = append(
298				opts,
299				list.WithCompletionShortcut(cmd.Shortcut),
300			)
301		}
302		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
303	}
304	return c.commandList.SetItems(commandItems)
305}
306
307func (c *commandDialogCmp) listHeight() int {
308	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
309	return min(listHeigh, c.wHeight/2)
310}
311
312func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
313	row, col := c.Position()
314	offset := row + 3
315	cursor.Y += offset
316	cursor.X = cursor.X + col + 2
317	return cursor
318}
319
320func (c *commandDialogCmp) style() lipgloss.Style {
321	t := styles.CurrentTheme()
322	return t.S().Base.
323		Width(c.width).
324		Border(lipgloss.RoundedBorder()).
325		BorderForeground(t.BorderFocus)
326}
327
328func (c *commandDialogCmp) Position() (int, int) {
329	row := c.wHeight/4 - 2 // just a bit above the center
330	col := c.wWidth / 2
331	col -= c.width / 2
332	return row, col
333}
334
335func (c *commandDialogCmp) defaultCommands() []Command {
336	commands := []Command{
337		{
338			ID:          "new_session",
339			Title:       "New Session",
340			Description: "start a new session",
341			Shortcut:    "ctrl+n",
342			Handler: func(cmd Command) tea.Cmd {
343				return util.CmdHandler(NewSessionsMsg{})
344			},
345		},
346		{
347			ID:          "switch_session",
348			Title:       "Switch Session",
349			Description: "Switch to a different session",
350			Shortcut:    "ctrl+s",
351			Handler: func(cmd Command) tea.Cmd {
352				return util.CmdHandler(SwitchSessionsMsg{})
353			},
354		},
355		{
356			ID:          "switch_model",
357			Title:       "Switch Model",
358			Description: "Switch to a different model",
359			Handler: func(cmd Command) tea.Cmd {
360				return util.CmdHandler(SwitchModelMsg{})
361			},
362		},
363	}
364
365	// Only show compact command if there's an active session
366	if c.sessionID != "" {
367		commands = append(commands, Command{
368			ID:          "Summarize",
369			Title:       "Summarize Session",
370			Description: "Summarize the current session and create a new one with the summary",
371			Handler: func(cmd Command) tea.Cmd {
372				return util.CmdHandler(CompactMsg{
373					SessionID: c.sessionID,
374				})
375			},
376		})
377	}
378
379	// Add reasoning toggle for models that support it
380	cfg := config.Get()
381	if agentCfg, ok := cfg.Agents["coder"]; ok {
382		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
383		model := cfg.GetModelByType(agentCfg.Model)
384		if providerCfg != nil && model != nil && model.CanReason {
385			selectedModel := cfg.Models[agentCfg.Model]
386
387			// Anthropic models: thinking toggle
388			if providerCfg.Type == catwalk.TypeAnthropic {
389				status := "Enable"
390				if selectedModel.Think {
391					status = "Disable"
392				}
393				commands = append(commands, Command{
394					ID:          "toggle_thinking",
395					Title:       status + " Thinking Mode",
396					Description: "Toggle model thinking for reasoning-capable models",
397					Handler: func(cmd Command) tea.Cmd {
398						return util.CmdHandler(ToggleThinkingMsg{})
399					},
400				})
401			}
402
403			// OpenAI models: reasoning effort dialog
404			if providerCfg.Type == catwalk.TypeOpenAI && model.HasReasoningEffort {
405				commands = append(commands, Command{
406					ID:          "select_reasoning_effort",
407					Title:       "Select Reasoning Effort",
408					Description: "Choose reasoning effort level (low/medium/high)",
409					Handler: func(cmd Command) tea.Cmd {
410						return util.CmdHandler(OpenReasoningDialogMsg{})
411					},
412				})
413			}
414		}
415	}
416	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
417	if c.wWidth > 120 && c.sessionID != "" {
418		commands = append(commands, Command{
419			ID:          "toggle_sidebar",
420			Title:       "Toggle Sidebar",
421			Description: "Toggle between compact and normal layout",
422			Handler: func(cmd Command) tea.Cmd {
423				return util.CmdHandler(ToggleCompactModeMsg{})
424			},
425		})
426	}
427	if c.sessionID != "" {
428		agentCfg := config.Get().Agents["coder"]
429		model := config.Get().GetModelByType(agentCfg.Model)
430		if model.SupportsImages {
431			commands = append(commands, Command{
432				ID:          "file_picker",
433				Title:       "Open File Picker",
434				Shortcut:    "ctrl+f",
435				Description: "Open file picker",
436				Handler: func(cmd Command) tea.Cmd {
437					return util.CmdHandler(OpenFilePickerMsg{})
438				},
439			})
440		}
441	}
442
443	// Add external editor command if $EDITOR is available
444	if os.Getenv("EDITOR") != "" {
445		commands = append(commands, Command{
446			ID:          "open_external_editor",
447			Title:       "Open External Editor",
448			Shortcut:    "ctrl+o",
449			Description: "Open external editor to compose message",
450			Handler: func(cmd Command) tea.Cmd {
451				return util.CmdHandler(OpenExternalEditorMsg{})
452			},
453		})
454	}
455
456	return append(commands, []Command{
457		{
458			ID:          "toggle_yolo",
459			Title:       "Toggle Yolo Mode",
460			Description: "Toggle yolo mode",
461			Handler: func(cmd Command) tea.Cmd {
462				return util.CmdHandler(ToggleYoloModeMsg{})
463			},
464		},
465		{
466			ID:          "toggle_help",
467			Title:       "Toggle Help",
468			Shortcut:    "ctrl+g",
469			Description: "Toggle help",
470			Handler: func(cmd Command) tea.Cmd {
471				return util.CmdHandler(ToggleHelpMsg{})
472			},
473		},
474		{
475			ID:          "init",
476			Title:       "Initialize Project",
477			Description: "Create/Update the CRUSH.md memory file",
478			Handler: func(cmd Command) tea.Cmd {
479				return util.CmdHandler(chat.SendMsg{
480					Text: prompt.Initialize(),
481				})
482			},
483		},
484		{
485			ID:          "quit",
486			Title:       "Quit",
487			Description: "Quit",
488			Shortcut:    "ctrl+c",
489			Handler: func(cmd Command) tea.Cmd {
490				return util.CmdHandler(QuitMsg{})
491			},
492		},
493	}...)
494}
495
496func (c *commandDialogCmp) ID() dialogs.DialogID {
497	return CommandsDialogID
498}