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