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