commands.go

  1package commands
  2
  3import (
  4	"fmt"
  5	"os"
  6	"slices"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/help"
 10	"charm.land/bubbles/v2/key"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/charmbracelet/catwalk/pkg/catwalk"
 14
 15	"github.com/charmbracelet/crush/internal/agent"
 16	"github.com/charmbracelet/crush/internal/agent/hyper"
 17	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 18	"github.com/charmbracelet/crush/internal/config"
 19	"github.com/charmbracelet/crush/internal/csync"
 20	"github.com/charmbracelet/crush/internal/pubsub"
 21	"github.com/charmbracelet/crush/internal/tui/components/chat"
 22	"github.com/charmbracelet/crush/internal/tui/components/core"
 23	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 24	"github.com/charmbracelet/crush/internal/tui/exp/list"
 25	"github.com/charmbracelet/crush/internal/tui/styles"
 26	"github.com/charmbracelet/crush/internal/tui/util"
 27	"github.com/charmbracelet/crush/internal/uicmd"
 28)
 29
 30const (
 31	CommandsDialogID dialogs.DialogID = "commands"
 32
 33	defaultWidth int = 70
 34)
 35
 36type commandType = uicmd.CommandType
 37
 38const (
 39	SystemCommands = uicmd.SystemCommands
 40	UserCommands   = uicmd.UserCommands
 41	MCPPrompts     = uicmd.MCPPrompts
 42)
 43
 44type listModel = list.FilterableList[list.CompletionItem[Command]]
 45
 46// Command represents a command that can be executed
 47type (
 48	Command                         = uicmd.Command
 49	CommandRunCustomMsg             = uicmd.CommandRunCustomMsg
 50	ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
 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   *csync.Slice[Command] // MCP prompts
 69	sessionID    string                // Current session ID
 70}
 71
 72type (
 73	SwitchSessionsMsg      struct{}
 74	NewSessionsMsg         struct{}
 75	SwitchModelMsg         struct{}
 76	QuitMsg                struct{}
 77	OpenFilePickerMsg      struct{}
 78	ToggleHelpMsg          struct{}
 79	ToggleCompactModeMsg   struct{}
 80	ToggleThinkingMsg      struct{}
 81	OpenReasoningDialogMsg struct{}
 82	OpenExternalEditorMsg  struct{}
 83	ToggleYoloModeMsg      struct{}
 84	CompactMsg             struct {
 85		SessionID string
 86	}
 87)
 88
 89func NewCommandDialog(sessionID string) CommandsDialog {
 90	keyMap := DefaultCommandsDialogKeyMap()
 91	listKeyMap := list.DefaultKeyMap()
 92	listKeyMap.Down.SetEnabled(false)
 93	listKeyMap.Up.SetEnabled(false)
 94	listKeyMap.DownOneItem = keyMap.Next
 95	listKeyMap.UpOneItem = keyMap.Previous
 96
 97	t := styles.CurrentTheme()
 98	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
 99	commandList := list.NewFilterableList(
100		[]list.CompletionItem[Command]{},
101		list.WithFilterInputStyle(inputStyle),
102		list.WithFilterListOptions(
103			list.WithKeyMap(listKeyMap),
104			list.WithWrapNavigation(),
105			list.WithResizeByList(),
106		),
107	)
108	help := help.New()
109	help.Styles = t.S().Help
110	return &commandDialogCmp{
111		commandList: commandList,
112		width:       defaultWidth,
113		keyMap:      DefaultCommandsDialogKeyMap(),
114		help:        help,
115		selected:    SystemCommands,
116		sessionID:   sessionID,
117		mcpPrompts:  csync.NewSlice[Command](),
118	}
119}
120
121func (c *commandDialogCmp) Init() tea.Cmd {
122	commands, err := uicmd.LoadCustomCommands()
123	if err != nil {
124		return util.ReportError(err)
125	}
126	c.userCommands = commands
127	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
128	return c.setCommandType(c.selected)
129}
130
131func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
132	switch msg := msg.(type) {
133	case tea.WindowSizeMsg:
134		c.wWidth = msg.Width
135		c.wHeight = msg.Height
136		return c, tea.Batch(
137			c.setCommandType(c.selected),
138			c.commandList.SetSize(c.listWidth(), c.listHeight()),
139		)
140	case pubsub.Event[mcp.Event]:
141		// Reload MCP prompts when MCP state changes
142		if msg.Type == pubsub.UpdatedEvent {
143			c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
144			// If we're currently viewing MCP prompts, refresh the list
145			if c.selected == MCPPrompts {
146				return c, c.setCommandType(MCPPrompts)
147			}
148			return c, nil
149		}
150	case tea.KeyPressMsg:
151		switch {
152		case key.Matches(msg, c.keyMap.Select):
153			selectedItem := c.commandList.SelectedItem()
154			if selectedItem == nil {
155				return c, nil // No item selected, do nothing
156			}
157			command := (*selectedItem).Value()
158			return c, tea.Sequence(
159				util.CmdHandler(dialogs.CloseDialogMsg{}),
160				command.Handler(command),
161			)
162		case key.Matches(msg, c.keyMap.Tab):
163			if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
164				return c, nil
165			}
166			return c, c.setCommandType(c.next())
167		case key.Matches(msg, c.keyMap.Close):
168			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
169		default:
170			u, cmd := c.commandList.Update(msg)
171			c.commandList = u.(listModel)
172			return c, cmd
173		}
174	}
175	return c, nil
176}
177
178func (c *commandDialogCmp) next() commandType {
179	switch c.selected {
180	case SystemCommands:
181		if len(c.userCommands) > 0 {
182			return UserCommands
183		}
184		if c.mcpPrompts.Len() > 0 {
185			return MCPPrompts
186		}
187		fallthrough
188	case UserCommands:
189		if c.mcpPrompts.Len() > 0 {
190			return MCPPrompts
191		}
192		fallthrough
193	case MCPPrompts:
194		return SystemCommands
195	default:
196		return SystemCommands
197	}
198}
199
200func (c *commandDialogCmp) View() string {
201	t := styles.CurrentTheme()
202	listView := c.commandList
203	radio := c.commandTypeRadio()
204
205	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
206	if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
207		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
208	}
209	content := lipgloss.JoinVertical(
210		lipgloss.Left,
211		header,
212		listView.View(),
213		"",
214		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
215	)
216	return c.style().Render(content)
217}
218
219func (c *commandDialogCmp) Cursor() *tea.Cursor {
220	if cursor, ok := c.commandList.(util.Cursor); ok {
221		cursor := cursor.Cursor()
222		if cursor != nil {
223			cursor = c.moveCursor(cursor)
224		}
225		return cursor
226	}
227	return nil
228}
229
230func (c *commandDialogCmp) commandTypeRadio() string {
231	t := styles.CurrentTheme()
232
233	fn := func(i commandType) string {
234		if i == c.selected {
235			return "◉ " + i.String()
236		}
237		return "○ " + i.String()
238	}
239
240	parts := []string{
241		fn(SystemCommands),
242	}
243	if len(c.userCommands) > 0 {
244		parts = append(parts, fn(UserCommands))
245	}
246	if c.mcpPrompts.Len() > 0 {
247		parts = append(parts, fn(MCPPrompts))
248	}
249	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
250}
251
252func (c *commandDialogCmp) listWidth() int {
253	return defaultWidth - 2 // 4 for padding
254}
255
256func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
257	c.selected = commandType
258
259	var commands []Command
260	switch c.selected {
261	case SystemCommands:
262		commands = c.defaultCommands()
263	case UserCommands:
264		commands = c.userCommands
265	case MCPPrompts:
266		commands = slices.Collect(c.mcpPrompts.Seq())
267	}
268
269	commandItems := []list.CompletionItem[Command]{}
270	for _, cmd := range commands {
271		opts := []list.CompletionItemOption{
272			list.WithCompletionID(cmd.ID),
273		}
274		if cmd.Shortcut != "" {
275			opts = append(
276				opts,
277				list.WithCompletionShortcut(cmd.Shortcut),
278			)
279		}
280		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
281	}
282	return c.commandList.SetItems(commandItems)
283}
284
285func (c *commandDialogCmp) listHeight() int {
286	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
287	return min(listHeigh, c.wHeight/2)
288}
289
290func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
291	row, col := c.Position()
292	offset := row + 3
293	cursor.Y += offset
294	cursor.X = cursor.X + col + 2
295	return cursor
296}
297
298func (c *commandDialogCmp) style() lipgloss.Style {
299	t := styles.CurrentTheme()
300	return t.S().Base.
301		Width(c.width).
302		Border(lipgloss.RoundedBorder()).
303		BorderForeground(t.BorderFocus)
304}
305
306func (c *commandDialogCmp) Position() (int, int) {
307	row := c.wHeight/4 - 2 // just a bit above the center
308	col := c.wWidth / 2
309	col -= c.width / 2
310	return row, col
311}
312
313func (c *commandDialogCmp) defaultCommands() []Command {
314	commands := []Command{
315		{
316			ID:          "new_session",
317			Title:       "New Session",
318			Description: "start a new session",
319			Shortcut:    "ctrl+n",
320			Handler: func(cmd Command) tea.Cmd {
321				return util.CmdHandler(NewSessionsMsg{})
322			},
323		},
324		{
325			ID:          "switch_session",
326			Title:       "Switch Session",
327			Description: "Switch to a different session",
328			Shortcut:    "ctrl+s",
329			Handler: func(cmd Command) tea.Cmd {
330				return util.CmdHandler(SwitchSessionsMsg{})
331			},
332		},
333		{
334			ID:          "switch_model",
335			Title:       "Switch Model",
336			Description: "Switch to a different model",
337			Shortcut:    "ctrl+l",
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[config.AgentCoder]; 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 || providerCfg.Type == catwalk.Type(hyper.Name) {
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 len(model.ReasoningLevels) > 0 {
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[config.AgentCoder]
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: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
457			Handler: func(cmd Command) tea.Cmd {
458				initPrompt, err := agent.InitializePrompt(*config.Get())
459				if err != nil {
460					return util.ReportError(err)
461				}
462				return util.CmdHandler(chat.SendMsg{
463					Text: initPrompt,
464				})
465			},
466		},
467		{
468			ID:          "quit",
469			Title:       "Quit",
470			Description: "Quit",
471			Shortcut:    "ctrl+c",
472			Handler: func(cmd Command) tea.Cmd {
473				return util.CmdHandler(QuitMsg{})
474			},
475		},
476	}...)
477}
478
479func (c *commandDialogCmp) ID() dialogs.DialogID {
480	return CommandsDialogID
481}