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