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