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