commands.go

  1package dialog
  2
  3import (
  4	"os"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/help"
  8	"charm.land/bubbles/v2/key"
  9	"charm.land/bubbles/v2/spinner"
 10	"charm.land/bubbles/v2/textinput"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/charmbracelet/catwalk/pkg/catwalk"
 14	"github.com/charmbracelet/crush/internal/commands"
 15	"github.com/charmbracelet/crush/internal/config"
 16	"github.com/charmbracelet/crush/internal/ui/common"
 17	"github.com/charmbracelet/crush/internal/ui/list"
 18	"github.com/charmbracelet/crush/internal/ui/styles"
 19	uv "github.com/charmbracelet/ultraviolet"
 20	"github.com/charmbracelet/x/ansi"
 21)
 22
 23// CommandsID is the identifier for the commands dialog.
 24const CommandsID = "commands"
 25
 26// CommandType represents the type of commands being displayed.
 27type CommandType uint
 28
 29// String returns the string representation of the CommandType.
 30func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
 31
 32const sidebarCompactModeBreakpoint = 120
 33
 34const (
 35	SystemCommands CommandType = iota
 36	UserCommands
 37	MCPPrompts
 38)
 39
 40// Commands represents a dialog that shows available commands.
 41type Commands struct {
 42	com    *common.Common
 43	keyMap struct {
 44		Select,
 45		UpDown,
 46		Next,
 47		Previous,
 48		Tab,
 49		ShiftTab,
 50		Close key.Binding
 51	}
 52
 53	sessionID string // can be empty for non-session-specific commands
 54	selected  CommandType
 55
 56	spinner spinner.Model
 57	loading bool
 58
 59	help  help.Model
 60	input textinput.Model
 61	list  *list.FilterableList
 62
 63	windowWidth int
 64
 65	customCommands []commands.CustomCommand
 66	mcpPrompts     []commands.MCPPrompt
 67}
 68
 69var _ Dialog = (*Commands)(nil)
 70
 71// NewCommands creates a new commands dialog.
 72func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
 73	c := &Commands{
 74		com:            com,
 75		selected:       SystemCommands,
 76		sessionID:      sessionID,
 77		customCommands: customCommands,
 78		mcpPrompts:     mcpPrompts,
 79	}
 80
 81	help := help.New()
 82	help.Styles = com.Styles.DialogHelpStyles()
 83
 84	c.help = help
 85
 86	c.list = list.NewFilterableList()
 87	c.list.Focus()
 88	c.list.SetSelected(0)
 89
 90	c.input = textinput.New()
 91	c.input.SetVirtualCursor(false)
 92	c.input.Placeholder = "Type to filter"
 93	c.input.SetStyles(com.Styles.TextInput)
 94	c.input.Focus()
 95
 96	c.keyMap.Select = key.NewBinding(
 97		key.WithKeys("enter", "ctrl+y"),
 98		key.WithHelp("enter", "confirm"),
 99	)
100	c.keyMap.UpDown = key.NewBinding(
101		key.WithKeys("up", "down"),
102		key.WithHelp("↑/↓", "choose"),
103	)
104	c.keyMap.Next = key.NewBinding(
105		key.WithKeys("down"),
106		key.WithHelp("↓", "next item"),
107	)
108	c.keyMap.Previous = key.NewBinding(
109		key.WithKeys("up", "ctrl+p"),
110		key.WithHelp("↑", "previous item"),
111	)
112	c.keyMap.Tab = key.NewBinding(
113		key.WithKeys("tab"),
114		key.WithHelp("tab", "switch selection"),
115	)
116	c.keyMap.ShiftTab = key.NewBinding(
117		key.WithKeys("shift+tab"),
118		key.WithHelp("shift+tab", "switch selection prev"),
119	)
120	closeKey := CloseKey
121	closeKey.SetHelp("esc", "cancel")
122	c.keyMap.Close = closeKey
123
124	// Set initial commands
125	c.setCommandItems(c.selected)
126
127	s := spinner.New()
128	s.Spinner = spinner.Dot
129	s.Style = com.Styles.Dialog.Spinner
130	c.spinner = s
131
132	return c, nil
133}
134
135// ID implements Dialog.
136func (c *Commands) ID() string {
137	return CommandsID
138}
139
140// HandleMsg implements [Dialog].
141func (c *Commands) HandleMsg(msg tea.Msg) Action {
142	switch msg := msg.(type) {
143	case spinner.TickMsg:
144		if c.loading {
145			var cmd tea.Cmd
146			c.spinner, cmd = c.spinner.Update(msg)
147			return ActionCmd{Cmd: cmd}
148		}
149	case tea.KeyPressMsg:
150		switch {
151		case key.Matches(msg, c.keyMap.Close):
152			return ActionClose{}
153		case key.Matches(msg, c.keyMap.Previous):
154			c.list.Focus()
155			if c.list.IsSelectedFirst() {
156				c.list.SelectLast()
157				c.list.ScrollToBottom()
158				break
159			}
160			c.list.SelectPrev()
161			c.list.ScrollToSelected()
162		case key.Matches(msg, c.keyMap.Next):
163			c.list.Focus()
164			if c.list.IsSelectedLast() {
165				c.list.SelectFirst()
166				c.list.ScrollToTop()
167				break
168			}
169			c.list.SelectNext()
170			c.list.ScrollToSelected()
171		case key.Matches(msg, c.keyMap.Select):
172			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
173				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
174					return item.Action()
175				}
176			}
177		case key.Matches(msg, c.keyMap.Tab):
178			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
179				c.selected = c.nextCommandType()
180				c.setCommandItems(c.selected)
181			}
182		case key.Matches(msg, c.keyMap.ShiftTab):
183			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
184				c.selected = c.previousCommandType()
185				c.setCommandItems(c.selected)
186			}
187		default:
188			var cmd tea.Cmd
189			for _, item := range c.list.VisibleItems() {
190				if item, ok := item.(*CommandItem); ok && item != nil {
191					if msg.String() == item.Shortcut() {
192						return item.Action()
193					}
194				}
195			}
196			c.input, cmd = c.input.Update(msg)
197			value := c.input.Value()
198			c.list.SetFilter(value)
199			c.list.ScrollToTop()
200			c.list.SetSelected(0)
201			return ActionCmd{cmd}
202		}
203	}
204	return nil
205}
206
207// Cursor returns the cursor position relative to the dialog.
208func (c *Commands) Cursor() *tea.Cursor {
209	return InputCursor(c.com.Styles, c.input.Cursor())
210}
211
212// commandsRadioView generates the command type selector radio buttons.
213func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
214	if !hasUserCmds && !hasMCPPrompts {
215		return ""
216	}
217
218	selectedFn := func(t CommandType) string {
219		if t == selected {
220			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
221		}
222		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
223	}
224
225	parts := []string{
226		selectedFn(SystemCommands),
227	}
228
229	if hasUserCmds {
230		parts = append(parts, selectedFn(UserCommands))
231	}
232	if hasMCPPrompts {
233		parts = append(parts, selectedFn(MCPPrompts))
234	}
235
236	return strings.Join(parts, " ")
237}
238
239// Draw implements [Dialog].
240func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
241	t := c.com.Styles
242	width := max(0, min(defaultDialogMaxWidth, area.Dx()))
243	height := max(0, min(defaultDialogHeight, area.Dy()))
244	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
245		c.windowWidth = area.Dx()
246		// since some items in the list depend on width (e.g. toggle sidebar command),
247		// we need to reset the command items when width changes
248		c.setCommandItems(c.selected)
249	}
250	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
251	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
252		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
253		t.Dialog.HelpView.GetVerticalFrameSize() +
254		t.Dialog.View.GetVerticalFrameSize()
255
256	c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
257	c.list.SetSize(innerWidth, height-heightOffset)
258	c.help.SetWidth(innerWidth)
259
260	radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
261	titleStyle := t.Dialog.Title
262	dialogStyle := t.Dialog.View.Width(width)
263	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
264	helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
265	header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
266
267	if c.loading {
268		helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
269	}
270	view := HeaderInputListHelpView(t, width, c.list.Height(), header,
271		c.input.View(), c.list.Render(), helpView)
272
273	cur := c.Cursor()
274	DrawCenterCursor(scr, area, view, cur)
275	return cur
276}
277
278// ShortHelp implements [help.KeyMap].
279func (c *Commands) ShortHelp() []key.Binding {
280	return []key.Binding{
281		c.keyMap.Tab,
282		c.keyMap.UpDown,
283		c.keyMap.Select,
284		c.keyMap.Close,
285	}
286}
287
288// FullHelp implements [help.KeyMap].
289func (c *Commands) FullHelp() [][]key.Binding {
290	return [][]key.Binding{
291		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
292		{c.keyMap.Close},
293	}
294}
295
296// nextCommandType returns the next command type in the cycle.
297func (c *Commands) nextCommandType() CommandType {
298	switch c.selected {
299	case SystemCommands:
300		if len(c.customCommands) > 0 {
301			return UserCommands
302		}
303		if len(c.mcpPrompts) > 0 {
304			return MCPPrompts
305		}
306		fallthrough
307	case UserCommands:
308		if len(c.mcpPrompts) > 0 {
309			return MCPPrompts
310		}
311		fallthrough
312	case MCPPrompts:
313		return SystemCommands
314	default:
315		return SystemCommands
316	}
317}
318
319// previousCommandType returns the previous command type in the cycle.
320func (c *Commands) previousCommandType() CommandType {
321	switch c.selected {
322	case SystemCommands:
323		if len(c.mcpPrompts) > 0 {
324			return MCPPrompts
325		}
326		if len(c.customCommands) > 0 {
327			return UserCommands
328		}
329		return SystemCommands
330	case UserCommands:
331		return SystemCommands
332	case MCPPrompts:
333		if len(c.customCommands) > 0 {
334			return UserCommands
335		}
336		return SystemCommands
337	default:
338		return SystemCommands
339	}
340}
341
342// setCommandItems sets the command items based on the specified command type.
343func (c *Commands) setCommandItems(commandType CommandType) {
344	c.selected = commandType
345
346	commandItems := []list.FilterableItem{}
347	switch c.selected {
348	case SystemCommands:
349		for _, cmd := range c.defaultCommands() {
350			commandItems = append(commandItems, cmd)
351		}
352	case UserCommands:
353		for _, cmd := range c.customCommands {
354			action := ActionRunCustomCommand{
355				Content:   cmd.Content,
356				Arguments: cmd.Arguments,
357			}
358			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
359		}
360	case MCPPrompts:
361		for _, cmd := range c.mcpPrompts {
362			action := ActionRunMCPPrompt{
363				Title:       cmd.Title,
364				Description: cmd.Description,
365				PromptID:    cmd.PromptID,
366				ClientID:    cmd.ClientID,
367				Arguments:   cmd.Arguments,
368			}
369			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
370		}
371	}
372
373	c.list.SetItems(commandItems...)
374	c.list.SetFilter("")
375	c.list.ScrollToTop()
376	c.list.SetSelected(0)
377	c.input.SetValue("")
378}
379
380// defaultCommands returns the list of default system commands.
381func (c *Commands) defaultCommands() []*CommandItem {
382	commands := []*CommandItem{
383		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
384		NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
385		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
386	}
387
388	// Only show compact command if there's an active session
389	if c.sessionID != "" {
390		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
391	}
392
393	// Add reasoning toggle for models that support it
394	cfg := c.com.Config()
395	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
396		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
397		model := cfg.GetModelByType(agentCfg.Model)
398		if providerCfg != nil && model != nil && model.CanReason {
399			selectedModel := cfg.Models[agentCfg.Model]
400
401			// Anthropic models: thinking toggle
402			if providerCfg.Type == catwalk.TypeAnthropic {
403				status := "Enable"
404				if selectedModel.Think {
405					status = "Disable"
406				}
407				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
408			}
409
410			// OpenAI models: reasoning effort dialog
411			if len(model.ReasoningLevels) > 0 {
412				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
413					// TODO: Pass in the reasoning effort dialog id
414				}))
415			}
416		}
417	}
418	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
419	if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
420		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
421	}
422	if c.sessionID != "" {
423		cfg := c.com.Config()
424		agentCfg := cfg.Agents[config.AgentCoder]
425		model := cfg.GetModelByType(agentCfg.Model)
426		if model != nil && model.SupportsImages {
427			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
428				// TODO: Pass in the file picker dialog id
429			}))
430		}
431	}
432
433	// Add external editor command if $EDITOR is available
434	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
435	if os.Getenv("EDITOR") != "" {
436		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
437	}
438
439	return append(commands,
440		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
441		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
442		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
443		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
444	)
445}
446
447// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
448func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
449	c.customCommands = customCommands
450	if c.selected == UserCommands {
451		c.setCommandItems(c.selected)
452	}
453}
454
455// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
456func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
457	c.mcpPrompts = mcpPrompts
458	if c.selected == MCPPrompts {
459		c.setCommandItems(c.selected)
460	}
461}
462
463// StartLoading implements [LoadingDialog].
464func (a *Commands) StartLoading() tea.Cmd {
465	if a.loading {
466		return nil
467	}
468	a.loading = true
469	return a.spinner.Tick
470}
471
472// StopLoading implements [LoadingDialog].
473func (a *Commands) StopLoading() {
474	a.loading = false
475}