commands.go

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