commands.go

  1package dialog
  2
  3import (
  4	"fmt"
  5	"os"
  6	"slices"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/help"
 10	"charm.land/bubbles/v2/key"
 11	"charm.land/bubbles/v2/textinput"
 12	tea "charm.land/bubbletea/v2"
 13	"charm.land/lipgloss/v2"
 14	"github.com/charmbracelet/catwalk/pkg/catwalk"
 15	"github.com/charmbracelet/crush/internal/agent"
 16	"github.com/charmbracelet/crush/internal/config"
 17	"github.com/charmbracelet/crush/internal/csync"
 18	"github.com/charmbracelet/crush/internal/message"
 19	"github.com/charmbracelet/crush/internal/ui/common"
 20	"github.com/charmbracelet/crush/internal/ui/list"
 21	"github.com/charmbracelet/crush/internal/ui/styles"
 22	"github.com/charmbracelet/crush/internal/uicmd"
 23	"github.com/charmbracelet/crush/internal/uiutil"
 24)
 25
 26// CommandsID is the identifier for the commands dialog.
 27const CommandsID = "commands"
 28
 29// SendMsg represents a message to send a chat message.
 30// TODO: Move to chat package?
 31type SendMsg struct {
 32	Text        string
 33	Attachments []message.Attachment
 34}
 35
 36// Commands represents a dialog that shows available commands.
 37type Commands struct {
 38	com    *common.Common
 39	keyMap struct {
 40		Select,
 41		Next,
 42		Previous,
 43		Tab,
 44		Close key.Binding
 45	}
 46
 47	sessionID  string // can be empty for non-session-specific commands
 48	selected   uicmd.CommandType
 49	userCmds   []uicmd.Command
 50	mcpPrompts *csync.Slice[uicmd.Command]
 51
 52	help          help.Model
 53	input         textinput.Model
 54	list          *list.FilterableList
 55	width, height int
 56}
 57
 58var _ Dialog = (*Commands)(nil)
 59
 60// NewCommands creates a new commands dialog.
 61func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
 62	commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
 63	if err != nil {
 64		return nil, err
 65	}
 66
 67	mcpPrompts := csync.NewSlice[uicmd.Command]()
 68	mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
 69
 70	c := &Commands{
 71		com:        com,
 72		userCmds:   commands,
 73		selected:   uicmd.SystemCommands,
 74		mcpPrompts: mcpPrompts,
 75		sessionID:  sessionID,
 76	}
 77
 78	help := help.New()
 79	help.Styles = com.Styles.DialogHelpStyles()
 80
 81	c.help = help
 82
 83	c.list = list.NewFilterableList()
 84	c.list.Focus()
 85	c.list.SetSelected(0)
 86
 87	c.input = textinput.New()
 88	c.input.SetVirtualCursor(false)
 89	c.input.Placeholder = "Type to filter"
 90	c.input.SetStyles(com.Styles.TextInput)
 91	c.input.Focus()
 92
 93	c.keyMap.Select = key.NewBinding(
 94		key.WithKeys("enter", "ctrl+y"),
 95		key.WithHelp("enter", "confirm"),
 96	)
 97	c.keyMap.Next = key.NewBinding(
 98		key.WithKeys("down", "ctrl+n"),
 99		key.WithHelp("↓", "next item"),
100	)
101	c.keyMap.Previous = key.NewBinding(
102		key.WithKeys("up", "ctrl+p"),
103		key.WithHelp("↑", "previous item"),
104	)
105	c.keyMap.Tab = key.NewBinding(
106		key.WithKeys("tab"),
107		key.WithHelp("tab", "switch selection"),
108	)
109	closeKey := CloseKey
110	closeKey.SetHelp("esc", "cancel")
111	c.keyMap.Close = closeKey
112
113	// Set initial commands
114	c.setCommandType(c.selected)
115
116	return c, nil
117}
118
119// SetSize sets the size of the dialog.
120func (c *Commands) SetSize(width, height int) {
121	c.width = width
122	c.height = height
123	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
124	c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
125	c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
126	c.help.SetWidth(width)
127}
128
129// ID implements Dialog.
130func (c *Commands) ID() string {
131	return CommandsID
132}
133
134// Update implements Dialog.
135func (c *Commands) Update(msg tea.Msg) tea.Msg {
136	switch msg := msg.(type) {
137	case tea.KeyPressMsg:
138		switch {
139		case key.Matches(msg, c.keyMap.Close):
140			return CloseMsg{}
141		case key.Matches(msg, c.keyMap.Previous):
142			c.list.Focus()
143			c.list.SelectPrev()
144			c.list.ScrollToSelected()
145		case key.Matches(msg, c.keyMap.Next):
146			c.list.Focus()
147			c.list.SelectNext()
148			c.list.ScrollToSelected()
149		case key.Matches(msg, c.keyMap.Select):
150			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
151				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
152					// TODO: Please unravel this mess later and the Command
153					// Handler design.
154					if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh??
155						return cmd()
156					}
157				}
158			}
159		case key.Matches(msg, c.keyMap.Tab):
160			if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
161				c.selected = c.nextCommandType()
162				c.setCommandType(c.selected)
163			}
164		default:
165			var cmd tea.Cmd
166			c.input, cmd = c.input.Update(msg)
167			value := c.input.Value()
168			c.list.SetFilter(value)
169			c.list.ScrollToTop()
170			c.list.SetSelected(0)
171			return cmd
172		}
173	}
174	return nil
175}
176
177// ReloadMCPPrompts reloads the MCP prompts.
178func (c *Commands) ReloadMCPPrompts() tea.Cmd {
179	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
180	// If we're currently viewing MCP prompts, refresh the list
181	if c.selected == uicmd.MCPPrompts {
182		c.setCommandType(uicmd.MCPPrompts)
183	}
184	return nil
185}
186
187// Cursor returns the cursor position relative to the dialog.
188func (c *Commands) Cursor() *tea.Cursor {
189	return InputCursor(c.com.Styles, c.input.Cursor())
190}
191
192// radioView generates the command type selector radio buttons.
193func radioView(t *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
194	if !hasUserCmds && !hasMCPPrompts {
195		return ""
196	}
197
198	selectedFn := func(t uicmd.CommandType) string {
199		if t == selected {
200			return "◉ " + t.String()
201		}
202		return "○ " + t.String()
203	}
204
205	parts := []string{
206		selectedFn(uicmd.SystemCommands),
207	}
208	if hasUserCmds {
209		parts = append(parts, selectedFn(uicmd.UserCommands))
210	}
211	if hasMCPPrompts {
212		parts = append(parts, selectedFn(uicmd.MCPPrompts))
213	}
214
215	radio := strings.Join(parts, " ")
216	return t.Dialog.Commands.CommandTypeSelector.Render(radio)
217}
218
219// View implements [Dialog].
220func (c *Commands) View() string {
221	t := c.com.Styles
222	radio := radioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
223	titleStyle := t.Dialog.Title
224	dialogStyle := t.Dialog.View.Width(c.width)
225	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
226	header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
227	return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
228		c.input.View(), c.list.Render(), c.help.View(c))
229}
230
231// ShortHelp implements [help.KeyMap].
232func (c *Commands) ShortHelp() []key.Binding {
233	upDown := key.NewBinding(
234		key.WithKeys("up", "down"),
235		key.WithHelp("↑/↓", "choose"),
236	)
237	return []key.Binding{
238		c.keyMap.Tab,
239		upDown,
240		c.keyMap.Select,
241		c.keyMap.Close,
242	}
243}
244
245// FullHelp implements [help.KeyMap].
246func (c *Commands) FullHelp() [][]key.Binding {
247	return [][]key.Binding{
248		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
249		{c.keyMap.Close},
250	}
251}
252
253func (c *Commands) nextCommandType() uicmd.CommandType {
254	switch c.selected {
255	case uicmd.SystemCommands:
256		if len(c.userCmds) > 0 {
257			return uicmd.UserCommands
258		}
259		if c.mcpPrompts.Len() > 0 {
260			return uicmd.MCPPrompts
261		}
262		fallthrough
263	case uicmd.UserCommands:
264		if c.mcpPrompts.Len() > 0 {
265			return uicmd.MCPPrompts
266		}
267		fallthrough
268	case uicmd.MCPPrompts:
269		return uicmd.SystemCommands
270	default:
271		return uicmd.SystemCommands
272	}
273}
274
275func (c *Commands) setCommandType(commandType uicmd.CommandType) {
276	c.selected = commandType
277
278	var commands []uicmd.Command
279	switch c.selected {
280	case uicmd.SystemCommands:
281		commands = c.defaultCommands()
282	case uicmd.UserCommands:
283		commands = c.userCmds
284	case uicmd.MCPPrompts:
285		commands = slices.Collect(c.mcpPrompts.Seq())
286	}
287
288	commandItems := []list.FilterableItem{}
289	for _, cmd := range commands {
290		commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
291	}
292
293	c.list.SetItems(commandItems...)
294	c.list.SetSelected(0)
295	c.list.SetFilter("")
296	c.list.ScrollToTop()
297	c.list.SetSelected(0)
298	c.input.SetValue("")
299}
300
301// TODO: Rethink this
302func (c *Commands) defaultCommands() []uicmd.Command {
303	commands := []uicmd.Command{
304		{
305			ID:          "new_session",
306			Title:       "New Session",
307			Description: "start a new session",
308			Shortcut:    "ctrl+n",
309			Handler: func(cmd uicmd.Command) tea.Cmd {
310				return uiutil.CmdHandler(NewSessionsMsg{})
311			},
312		},
313		{
314			ID:          "switch_session",
315			Title:       "Switch Session",
316			Description: "Switch to a different session",
317			Shortcut:    "ctrl+s",
318			Handler: func(cmd uicmd.Command) tea.Cmd {
319				return uiutil.CmdHandler(SwitchSessionsMsg{})
320			},
321		},
322		{
323			ID:          "switch_model",
324			Title:       "Switch Model",
325			Description: "Switch to a different model",
326			Shortcut:    "ctrl+l",
327			Handler: func(cmd uicmd.Command) tea.Cmd {
328				return uiutil.CmdHandler(SwitchModelMsg{})
329			},
330		},
331	}
332
333	// Only show compact command if there's an active session
334	if c.sessionID != "" {
335		commands = append(commands, uicmd.Command{
336			ID:          "Summarize",
337			Title:       "Summarize Session",
338			Description: "Summarize the current session and create a new one with the summary",
339			Handler: func(cmd uicmd.Command) tea.Cmd {
340				return uiutil.CmdHandler(CompactMsg{
341					SessionID: c.sessionID,
342				})
343			},
344		})
345	}
346
347	// Add reasoning toggle for models that support it
348	cfg := c.com.Config()
349	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
350		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
351		model := cfg.GetModelByType(agentCfg.Model)
352		if providerCfg != nil && model != nil && model.CanReason {
353			selectedModel := cfg.Models[agentCfg.Model]
354
355			// Anthropic models: thinking toggle
356			if providerCfg.Type == catwalk.TypeAnthropic {
357				status := "Enable"
358				if selectedModel.Think {
359					status = "Disable"
360				}
361				commands = append(commands, uicmd.Command{
362					ID:          "toggle_thinking",
363					Title:       status + " Thinking Mode",
364					Description: "Toggle model thinking for reasoning-capable models",
365					Handler: func(cmd uicmd.Command) tea.Cmd {
366						return uiutil.CmdHandler(ToggleThinkingMsg{})
367					},
368				})
369			}
370
371			// OpenAI models: reasoning effort dialog
372			if len(model.ReasoningLevels) > 0 {
373				commands = append(commands, uicmd.Command{
374					ID:          "select_reasoning_effort",
375					Title:       "Select Reasoning Effort",
376					Description: "Choose reasoning effort level (low/medium/high)",
377					Handler: func(cmd uicmd.Command) tea.Cmd {
378						return uiutil.CmdHandler(OpenReasoningDialogMsg{})
379					},
380				})
381			}
382		}
383	}
384	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
385	// TODO: Get. Rid. Of. Magic. Numbers!
386	if c.width > 120 && c.sessionID != "" {
387		commands = append(commands, uicmd.Command{
388			ID:          "toggle_sidebar",
389			Title:       "Toggle Sidebar",
390			Description: "Toggle between compact and normal layout",
391			Handler: func(cmd uicmd.Command) tea.Cmd {
392				return uiutil.CmdHandler(ToggleCompactModeMsg{})
393			},
394		})
395	}
396	if c.sessionID != "" {
397		cfg := c.com.Config()
398		agentCfg := cfg.Agents[config.AgentCoder]
399		model := cfg.GetModelByType(agentCfg.Model)
400		if model.SupportsImages {
401			commands = append(commands, uicmd.Command{
402				ID:          "file_picker",
403				Title:       "Open File Picker",
404				Shortcut:    "ctrl+f",
405				Description: "Open file picker",
406				Handler: func(cmd uicmd.Command) tea.Cmd {
407					return uiutil.CmdHandler(OpenFilePickerMsg{})
408				},
409			})
410		}
411	}
412
413	// Add external editor command if $EDITOR is available
414	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
415	if os.Getenv("EDITOR") != "" {
416		commands = append(commands, uicmd.Command{
417			ID:          "open_external_editor",
418			Title:       "Open External Editor",
419			Shortcut:    "ctrl+o",
420			Description: "Open external editor to compose message",
421			Handler: func(cmd uicmd.Command) tea.Cmd {
422				return uiutil.CmdHandler(OpenExternalEditorMsg{})
423			},
424		})
425	}
426
427	return append(commands, []uicmd.Command{
428		{
429			ID:          "toggle_yolo",
430			Title:       "Toggle Yolo Mode",
431			Description: "Toggle yolo mode",
432			Handler: func(cmd uicmd.Command) tea.Cmd {
433				return uiutil.CmdHandler(ToggleYoloModeMsg{})
434			},
435		},
436		{
437			ID:          "toggle_help",
438			Title:       "Toggle Help",
439			Shortcut:    "ctrl+g",
440			Description: "Toggle help",
441			Handler: func(cmd uicmd.Command) tea.Cmd {
442				return uiutil.CmdHandler(ToggleHelpMsg{})
443			},
444		},
445		{
446			ID:          "init",
447			Title:       "Initialize Project",
448			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
449			Handler: func(cmd uicmd.Command) tea.Cmd {
450				initPrompt, err := agent.InitializePrompt(*c.com.Config())
451				if err != nil {
452					return uiutil.ReportError(err)
453				}
454				return uiutil.CmdHandler(SendMsg{
455					Text: initPrompt,
456				})
457			},
458		},
459		{
460			ID:          "quit",
461			Title:       "Quit",
462			Description: "Quit",
463			Shortcut:    "ctrl+c",
464			Handler: func(cmd uicmd.Command) tea.Cmd {
465				return uiutil.CmdHandler(tea.QuitMsg{})
466			},
467		},
468	}...)
469}