commands.go

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