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