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