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