commands.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"os"
  6	"slices"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/help"
 10	"charm.land/bubbles/v2/key"
 11	"charm.land/bubbles/v2/textinput"
 12	tea "charm.land/bubbletea/v2"
 13	"charm.land/lipgloss/v2"
 14	"github.com/charmbracelet/catwalk/pkg/catwalk"
 15	"github.com/charmbracelet/crush/internal/agent"
 16	"github.com/charmbracelet/crush/internal/config"
 17	"github.com/charmbracelet/crush/internal/csync"
 18	"github.com/charmbracelet/crush/internal/tui/components/chat"
 19	"github.com/charmbracelet/crush/internal/tui/util"
 20	"github.com/charmbracelet/crush/internal/ui/common"
 21	"github.com/charmbracelet/crush/internal/ui/list"
 22	"github.com/charmbracelet/crush/internal/uicmd"
 23)
 24
 25// CommandsID is the identifier for the commands dialog.
 26const CommandsID = "commands"
 27
 28// Messages for commands
 29type (
 30	SwitchSessionsMsg      struct{}
 31	NewSessionsMsg         struct{}
 32	SwitchModelMsg         struct{}
 33	QuitMsg                struct{}
 34	OpenFilePickerMsg      struct{}
 35	ToggleHelpMsg          struct{}
 36	ToggleCompactModeMsg   struct{}
 37	ToggleThinkingMsg      struct{}
 38	OpenReasoningDialogMsg struct{}
 39	OpenExternalEditorMsg  struct{}
 40	ToggleYoloModeMsg      struct{}
 41	CompactMsg             struct {
 42		SessionID string
 43	}
 44)
 45
 46// Commands represents a dialog that shows available commands.
 47type Commands struct {
 48	com    *common.Common
 49	keyMap struct {
 50		Select,
 51		Next,
 52		Previous,
 53		Tab,
 54		Close key.Binding
 55	}
 56
 57	sessionID  string // can be empty for non-session-specific commands
 58	selected   uicmd.CommandType
 59	userCmds   []uicmd.Command
 60	mcpPrompts *csync.Slice[uicmd.Command]
 61
 62	help          help.Model
 63	input         textinput.Model
 64	list          *list.FilterableList
 65	width, height int
 66}
 67
 68var _ Dialog = (*Commands)(nil)
 69
 70// NewCommands creates a new commands dialog.
 71func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 72	commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
 73	if err != nil {
 74		return nil, err
 75	}
 76
 77	mcpPrompts := csync.NewSlice[uicmd.Command]()
 78	mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
 79
 80	c := &Commands{
 81		com:        com,
 82		userCmds:   commands,
 83		selected:   uicmd.SystemCommands,
 84		mcpPrompts: mcpPrompts,
 85		sessionID:  sessionID,
 86	}
 87
 88	help := help.New()
 89	help.Styles = com.Styles.DialogHelpStyles()
 90
 91	c.help = help
 92
 93	c.list = list.NewFilterableList()
 94	c.list.Focus()
 95	c.list.SetSelected(0)
 96
 97	c.input = textinput.New()
 98	c.input.SetVirtualCursor(false)
 99	c.input.Placeholder = "Type to filter"
100	c.input.SetStyles(com.Styles.TextInput)
101	c.input.Focus()
102
103	c.keyMap.Select = key.NewBinding(
104		key.WithKeys("enter", "ctrl+y"),
105		key.WithHelp("enter", "confirm"),
106	)
107	c.keyMap.Next = key.NewBinding(
108		key.WithKeys("down", "ctrl+n"),
109		key.WithHelp("↓", "next item"),
110	)
111	c.keyMap.Previous = key.NewBinding(
112		key.WithKeys("up", "ctrl+p"),
113		key.WithHelp("↑", "previous item"),
114	)
115	c.keyMap.Tab = key.NewBinding(
116		key.WithKeys("tab"),
117		key.WithHelp("tab", "switch selection"),
118	)
119	closeKey := CloseKey
120	closeKey.SetHelp("esc", "cancel")
121	c.keyMap.Close = closeKey
122
123	// Set initial commands
124	c.setCommandType(c.selected)
125
126	return c, nil
127}
128
129// SetSize sets the size of the dialog.
130func (c *Commands) SetSize(width, height int) {
131	c.width = width
132	c.height = height
133	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
134	c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
135	c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
136	c.help.SetWidth(width)
137}
138
139// ID implements Dialog.
140func (c *Commands) ID() string {
141	return CommandsID
142}
143
144// Update implements Dialog.
145func (c *Commands) Update(msg tea.Msg) tea.Cmd {
146	switch msg := msg.(type) {
147	case tea.KeyPressMsg:
148		switch {
149		case key.Matches(msg, c.keyMap.Previous):
150			c.list.Focus()
151			c.list.SelectPrev()
152			c.list.ScrollToSelected()
153		case key.Matches(msg, c.keyMap.Next):
154			c.list.Focus()
155			c.list.SelectNext()
156			c.list.ScrollToSelected()
157		case key.Matches(msg, c.keyMap.Select):
158			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
159				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
160					return item.Cmd.Handler(item.Cmd) // Huh??
161				}
162			}
163		case key.Matches(msg, c.keyMap.Tab):
164			if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
165				c.selected = c.nextCommandType()
166				c.setCommandType(c.selected)
167			}
168		default:
169			var cmd tea.Cmd
170			c.input, cmd = c.input.Update(msg)
171			// Update the list filter
172			c.list.SetFilter(c.input.Value())
173			return cmd
174		}
175	}
176	return nil
177}
178
179// ReloadMCPPrompts reloads the MCP prompts.
180func (c *Commands) ReloadMCPPrompts() tea.Cmd {
181	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
182	// If we're currently viewing MCP prompts, refresh the list
183	if c.selected == uicmd.MCPPrompts {
184		c.setCommandType(uicmd.MCPPrompts)
185	}
186	return nil
187}
188
189// Cursor returns the cursor position relative to the dialog.
190func (c *Commands) Cursor() *tea.Cursor {
191	return c.input.Cursor()
192}
193
194// View implements [Dialog].
195func (c *Commands) View() string {
196	t := c.com.Styles
197	selectedFn := func(t uicmd.CommandType) string {
198		if t == c.selected {
199			return "◉ " + t.String()
200		}
201		return "○ " + t.String()
202	}
203
204	parts := []string{
205		selectedFn(uicmd.SystemCommands),
206	}
207	if len(c.userCmds) > 0 {
208		parts = append(parts, selectedFn(uicmd.UserCommands))
209	}
210	if c.mcpPrompts.Len() > 0 {
211		parts = append(parts, selectedFn(uicmd.MCPPrompts))
212	}
213
214	radio := strings.Join(parts, " ")
215	radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
216	if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
217		radio = " " + radio
218	}
219
220	titleStyle := t.Dialog.Title
221	helpStyle := t.Dialog.HelpView
222	dialogStyle := t.Dialog.View.Width(c.width)
223	inputStyle := t.Dialog.InputPrompt
224	helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
225
226	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
227	header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
228	title := titleStyle.Render(header)
229	help := helpStyle.Render(c.help.View(c))
230	listContent := c.list.Render()
231	if nlines := lipgloss.Height(listContent); nlines < c.list.Height() {
232		// pad the list content to avoid jumping when navigating
233		listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines))
234	}
235
236	content := strings.Join([]string{
237		title,
238		"",
239		inputStyle.Render(c.input.View()),
240		"",
241		c.list.Render(),
242		"",
243		help,
244	}, "\n")
245
246	return dialogStyle.Render(content)
247}
248
249// ShortHelp implements [help.KeyMap].
250func (c *Commands) ShortHelp() []key.Binding {
251	upDown := key.NewBinding(
252		key.WithKeys("up", "down"),
253		key.WithHelp("↑/↓", "choose"),
254	)
255	return []key.Binding{
256		c.keyMap.Tab,
257		upDown,
258		c.keyMap.Select,
259		c.keyMap.Close,
260	}
261}
262
263// FullHelp implements [help.KeyMap].
264func (c *Commands) FullHelp() [][]key.Binding {
265	return [][]key.Binding{
266		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
267		{c.keyMap.Close},
268	}
269}
270
271func (c *Commands) nextCommandType() uicmd.CommandType {
272	switch c.selected {
273	case uicmd.SystemCommands:
274		if len(c.userCmds) > 0 {
275			return uicmd.UserCommands
276		}
277		if c.mcpPrompts.Len() > 0 {
278			return uicmd.MCPPrompts
279		}
280		fallthrough
281	case uicmd.UserCommands:
282		if c.mcpPrompts.Len() > 0 {
283			return uicmd.MCPPrompts
284		}
285		fallthrough
286	case uicmd.MCPPrompts:
287		return uicmd.SystemCommands
288	default:
289		return uicmd.SystemCommands
290	}
291}
292
293func (c *Commands) setCommandType(commandType uicmd.CommandType) {
294	c.selected = commandType
295
296	var commands []uicmd.Command
297	switch c.selected {
298	case uicmd.SystemCommands:
299		commands = c.defaultCommands()
300	case uicmd.UserCommands:
301		commands = c.userCmds
302	case uicmd.MCPPrompts:
303		commands = slices.Collect(c.mcpPrompts.Seq())
304	}
305
306	commandItems := []list.FilterableItem{}
307	for _, cmd := range commands {
308		commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
309	}
310
311	c.list.SetItems(commandItems...)
312	// Reset selection and filter
313	c.list.SetSelected(0)
314	c.input.SetValue("")
315}
316
317// TODO: Rethink this
318func (c *Commands) defaultCommands() []uicmd.Command {
319	commands := []uicmd.Command{
320		{
321			ID:          "new_session",
322			Title:       "New Session",
323			Description: "start a new session",
324			Shortcut:    "ctrl+n",
325			Handler: func(cmd uicmd.Command) tea.Cmd {
326				return util.CmdHandler(NewSessionsMsg{})
327			},
328		},
329		{
330			ID:          "switch_session",
331			Title:       "Switch Session",
332			Description: "Switch to a different session",
333			Shortcut:    "ctrl+s",
334			Handler: func(cmd uicmd.Command) tea.Cmd {
335				return util.CmdHandler(SwitchSessionsMsg{})
336			},
337		},
338		{
339			ID:          "switch_model",
340			Title:       "Switch Model",
341			Description: "Switch to a different model",
342			Shortcut:    "ctrl+l",
343			Handler: func(cmd uicmd.Command) tea.Cmd {
344				return util.CmdHandler(SwitchModelMsg{})
345			},
346		},
347	}
348
349	// Only show compact command if there's an active session
350	if c.sessionID != "" {
351		commands = append(commands, uicmd.Command{
352			ID:          "Summarize",
353			Title:       "Summarize Session",
354			Description: "Summarize the current session and create a new one with the summary",
355			Handler: func(cmd uicmd.Command) tea.Cmd {
356				return util.CmdHandler(CompactMsg{
357					SessionID: c.sessionID,
358				})
359			},
360		})
361	}
362
363	// Add reasoning toggle for models that support it
364	cfg := c.com.Config()
365	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
366		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
367		model := cfg.GetModelByType(agentCfg.Model)
368		if providerCfg != nil && model != nil && model.CanReason {
369			selectedModel := cfg.Models[agentCfg.Model]
370
371			// Anthropic models: thinking toggle
372			if providerCfg.Type == catwalk.TypeAnthropic {
373				status := "Enable"
374				if selectedModel.Think {
375					status = "Disable"
376				}
377				commands = append(commands, uicmd.Command{
378					ID:          "toggle_thinking",
379					Title:       status + " Thinking Mode",
380					Description: "Toggle model thinking for reasoning-capable models",
381					Handler: func(cmd uicmd.Command) tea.Cmd {
382						return util.CmdHandler(ToggleThinkingMsg{})
383					},
384				})
385			}
386
387			// OpenAI models: reasoning effort dialog
388			if len(model.ReasoningLevels) > 0 {
389				commands = append(commands, uicmd.Command{
390					ID:          "select_reasoning_effort",
391					Title:       "Select Reasoning Effort",
392					Description: "Choose reasoning effort level (low/medium/high)",
393					Handler: func(cmd uicmd.Command) tea.Cmd {
394						return util.CmdHandler(OpenReasoningDialogMsg{})
395					},
396				})
397			}
398		}
399	}
400	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
401	// TODO: Get. Rid. Of. Magic. Numbers!
402	if c.width > 120 && c.sessionID != "" {
403		commands = append(commands, uicmd.Command{
404			ID:          "toggle_sidebar",
405			Title:       "Toggle Sidebar",
406			Description: "Toggle between compact and normal layout",
407			Handler: func(cmd uicmd.Command) tea.Cmd {
408				return util.CmdHandler(ToggleCompactModeMsg{})
409			},
410		})
411	}
412	if c.sessionID != "" {
413		cfg := c.com.Config()
414		agentCfg := cfg.Agents[config.AgentCoder]
415		model := cfg.GetModelByType(agentCfg.Model)
416		if model.SupportsImages {
417			commands = append(commands, uicmd.Command{
418				ID:          "file_picker",
419				Title:       "Open File Picker",
420				Shortcut:    "ctrl+f",
421				Description: "Open file picker",
422				Handler: func(cmd uicmd.Command) tea.Cmd {
423					return util.CmdHandler(OpenFilePickerMsg{})
424				},
425			})
426		}
427	}
428
429	// Add external editor command if $EDITOR is available
430	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
431	if os.Getenv("EDITOR") != "" {
432		commands = append(commands, uicmd.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 uicmd.Command) tea.Cmd {
438				return util.CmdHandler(OpenExternalEditorMsg{})
439			},
440		})
441	}
442
443	return append(commands, []uicmd.Command{
444		{
445			ID:          "toggle_yolo",
446			Title:       "Toggle Yolo Mode",
447			Description: "Toggle yolo mode",
448			Handler: func(cmd uicmd.Command) tea.Cmd {
449				return util.CmdHandler(ToggleYoloModeMsg{})
450			},
451		},
452		{
453			ID:          "toggle_help",
454			Title:       "Toggle Help",
455			Shortcut:    "ctrl+g",
456			Description: "Toggle help",
457			Handler: func(cmd uicmd.Command) tea.Cmd {
458				return util.CmdHandler(ToggleHelpMsg{})
459			},
460		},
461		{
462			ID:          "init",
463			Title:       "Initialize Project",
464			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
465			Handler: func(cmd uicmd.Command) tea.Cmd {
466				initPrompt, err := agent.InitializePrompt(*c.com.Config())
467				if err != nil {
468					return util.ReportError(err)
469				}
470				return util.CmdHandler(chat.SendMsg{
471					Text: initPrompt,
472				})
473			},
474		},
475		{
476			ID:          "quit",
477			Title:       "Quit",
478			Description: "Quit",
479			Shortcut:    "ctrl+c",
480			Handler: func(cmd uicmd.Command) tea.Cmd {
481				return util.CmdHandler(QuitMsg{})
482			},
483		},
484	}...)
485}