commands.go

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