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