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