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.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
256		}
257		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.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			action := ActionRunCustomCommand{
394				Content:   cmd.Content,
395				Arguments: cmd.Arguments,
396			}
397			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
398		}
399	case MCPPrompts:
400		for _, cmd := range c.mcpPrompts {
401			action := ActionRunMCPPrompt{
402				Title:       cmd.Title,
403				Description: cmd.Description,
404				PromptID:    cmd.PromptID,
405				ClientID:    cmd.ClientID,
406				Arguments:   cmd.Arguments,
407			}
408			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
409		}
410	}
411
412	c.list.SetItems(commandItems...)
413	c.list.SetFilter("")
414	c.list.ScrollToTop()
415	c.list.SetSelected(0)
416	c.input.SetValue("")
417}
418
419// defaultCommands returns the list of default system commands.
420func (c *Commands) defaultCommands() []*CommandItem {
421	commands := []*CommandItem{
422		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
423		NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
424		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
425	}
426
427	// Only show compact command if there's an active session
428	if c.hasSession {
429		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
430	}
431
432	// Add reasoning toggle for models that support it
433	cfg := c.com.Config()
434	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
435		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
436		model := cfg.GetModelByType(agentCfg.Model)
437		if providerCfg != nil && model != nil && model.CanReason {
438			selectedModel := cfg.Models[agentCfg.Model]
439
440			// Anthropic models: thinking toggle
441			if model.CanReason && len(model.ReasoningLevels) == 0 {
442				status := "Enable"
443				if selectedModel.Think {
444					status = "Disable"
445				}
446				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
447			}
448
449			// OpenAI models: reasoning effort dialog
450			if len(model.ReasoningLevels) > 0 {
451				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
452					DialogID: ReasoningID,
453				}))
454			}
455		}
456	}
457	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
458	if c.windowWidth >= sidebarCompactModeBreakpoint && c.hasSession {
459		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
460	}
461	if c.hasSession {
462		cfgPrime := c.com.Config()
463		agentCfg := cfgPrime.Agents[config.AgentCoder]
464		model := cfgPrime.GetModelByType(agentCfg.Model)
465		if model != nil && model.SupportsImages {
466			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
467				DialogID: FilePickerID,
468			}))
469		}
470	}
471
472	// Add external editor command if $EDITOR is available.
473	//
474	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv;
475	// because os.Getenv does IO is breaks the TEA paradigm and is generally an
476	// antipattern.
477	if os.Getenv("EDITOR") != "" {
478		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
479	}
480
481	// Add Docker MCP command if available and not already enabled.
482	if !cfg.IsDockerMCPEnabled() && c.dockerMCPAvailable != nil && *c.dockerMCPAvailable {
483		commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
484	}
485
486	// Add disable Docker MCP command if it's currently enabled
487	if cfg.IsDockerMCPEnabled() {
488		commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
489	}
490
491	if c.hasTodos || c.hasQueue {
492		var label string
493		switch {
494		case c.hasTodos && c.hasQueue:
495			label = "Toggle To-Dos/Queue"
496		case c.hasQueue:
497			label = "Toggle Queue"
498		default:
499			label = "Toggle To-Dos"
500		}
501		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
502	}
503
504	// Add a command for toggling notifications.
505	cfg = c.com.Config()
506	notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
507	notificationLabel := "Disable Notifications"
508	if notificationsDisabled {
509		notificationLabel = "Enable Notifications"
510	}
511	commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
512
513	commands = append(commands,
514		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
515		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
516		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
517	)
518
519	// Add transparent background toggle.
520	transparentLabel := "Disable Background Color"
521	if cfg != nil && cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent {
522		transparentLabel = "Enable Background Color"
523	}
524	commands = append(commands, NewCommandItem(c.com.Styles, "toggle_transparent", transparentLabel, "", ActionToggleTransparentBackground{}))
525
526	commands = append(commands,
527		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
528	)
529
530	return commands
531}
532
533// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
534func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
535	c.customCommands = customCommands
536	if c.selected == UserCommands {
537		c.setCommandItems(c.selected)
538	}
539}
540
541// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
542func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
543	c.mcpPrompts = mcpPrompts
544	if c.selected == MCPPrompts {
545		c.setCommandItems(c.selected)
546	}
547}
548
549// StartLoading implements [LoadingDialog].
550func (a *Commands) StartLoading() tea.Cmd {
551	if a.loading {
552		return nil
553	}
554	a.loading = true
555	return a.spinner.Tick
556}
557
558// StopLoading implements [LoadingDialog].
559func (a *Commands) StopLoading() {
560	a.loading = false
561}