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			if cmd != nil {
180				return cmd()
181			}
182		}
183	}
184	return nil
185}
186
187// ReloadMCPPrompts reloads the MCP prompts.
188func (c *Commands) ReloadMCPPrompts() tea.Cmd {
189	c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
190	// If we're currently viewing MCP prompts, refresh the list
191	if c.selected == uicmd.MCPPrompts {
192		c.setCommandType(uicmd.MCPPrompts)
193	}
194	return nil
195}
196
197// Cursor returns the cursor position relative to the dialog.
198func (c *Commands) Cursor() *tea.Cursor {
199	return InputCursor(c.com.Styles, c.input.Cursor())
200}
201
202// commandsRadioView generates the command type selector radio buttons.
203func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
204	if !hasUserCmds && !hasMCPPrompts {
205		return ""
206	}
207
208	selectedFn := func(t uicmd.CommandType) string {
209		if t == selected {
210			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
211		}
212		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
213	}
214
215	parts := []string{
216		selectedFn(uicmd.SystemCommands),
217	}
218
219	if hasUserCmds {
220		parts = append(parts, selectedFn(uicmd.UserCommands))
221	}
222	if hasMCPPrompts {
223		parts = append(parts, selectedFn(uicmd.MCPPrompts))
224	}
225
226	return strings.Join(parts, " ")
227}
228
229// View implements [Dialog].
230func (c *Commands) View() string {
231	t := c.com.Styles
232	radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
233	titleStyle := t.Dialog.Title
234	dialogStyle := t.Dialog.View.Width(c.width)
235	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
236	header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
237	return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
238		c.input.View(), c.list.Render(), c.help.View(c))
239}
240
241// ShortHelp implements [help.KeyMap].
242func (c *Commands) ShortHelp() []key.Binding {
243	upDown := key.NewBinding(
244		key.WithKeys("up", "down"),
245		key.WithHelp("↑/↓", "choose"),
246	)
247	return []key.Binding{
248		c.keyMap.Tab,
249		upDown,
250		c.keyMap.Select,
251		c.keyMap.Close,
252	}
253}
254
255// FullHelp implements [help.KeyMap].
256func (c *Commands) FullHelp() [][]key.Binding {
257	return [][]key.Binding{
258		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
259		{c.keyMap.Close},
260	}
261}
262
263func (c *Commands) nextCommandType() uicmd.CommandType {
264	switch c.selected {
265	case uicmd.SystemCommands:
266		if len(c.userCmds) > 0 {
267			return uicmd.UserCommands
268		}
269		if c.mcpPrompts.Len() > 0 {
270			return uicmd.MCPPrompts
271		}
272		fallthrough
273	case uicmd.UserCommands:
274		if c.mcpPrompts.Len() > 0 {
275			return uicmd.MCPPrompts
276		}
277		fallthrough
278	case uicmd.MCPPrompts:
279		return uicmd.SystemCommands
280	default:
281		return uicmd.SystemCommands
282	}
283}
284
285func (c *Commands) setCommandType(commandType uicmd.CommandType) {
286	c.selected = commandType
287
288	var commands []uicmd.Command
289	switch c.selected {
290	case uicmd.SystemCommands:
291		commands = c.defaultCommands()
292	case uicmd.UserCommands:
293		commands = c.userCmds
294	case uicmd.MCPPrompts:
295		commands = slices.Collect(c.mcpPrompts.Seq())
296	}
297
298	commandItems := []list.FilterableItem{}
299	for _, cmd := range commands {
300		commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
301	}
302
303	c.list.SetItems(commandItems...)
304	c.list.SetSelected(0)
305	c.list.SetFilter("")
306	c.list.ScrollToTop()
307	c.list.SetSelected(0)
308	c.input.SetValue("")
309}
310
311// TODO: Rethink this
312func (c *Commands) defaultCommands() []uicmd.Command {
313	commands := []uicmd.Command{
314		{
315			ID:          "new_session",
316			Title:       "New Session",
317			Description: "start a new session",
318			Shortcut:    "ctrl+n",
319			Handler: func(cmd uicmd.Command) tea.Cmd {
320				return uiutil.CmdHandler(NewSessionsMsg{})
321			},
322		},
323		{
324			ID:          "switch_session",
325			Title:       "Switch Session",
326			Description: "Switch to a different session",
327			Shortcut:    "ctrl+s",
328			Handler: func(cmd uicmd.Command) tea.Cmd {
329				return uiutil.CmdHandler(OpenDialogMsg{SessionsID})
330			},
331		},
332		{
333			ID:          "switch_model",
334			Title:       "Switch Model",
335			Description: "Switch to a different model",
336			// FIXME: The shortcut might get updated if enhanced keyboard is supported.
337			Shortcut: "ctrl+l",
338			Handler: func(cmd uicmd.Command) tea.Cmd {
339				return uiutil.CmdHandler(OpenDialogMsg{ModelsID})
340			},
341		},
342	}
343
344	// Only show compact command if there's an active session
345	if c.sessionID != "" {
346		commands = append(commands, uicmd.Command{
347			ID:          "Summarize",
348			Title:       "Summarize Session",
349			Description: "Summarize the current session and create a new one with the summary",
350			Handler: func(cmd uicmd.Command) tea.Cmd {
351				return uiutil.CmdHandler(CompactMsg{
352					SessionID: c.sessionID,
353				})
354			},
355		})
356	}
357
358	// Add reasoning toggle for models that support it
359	cfg := c.com.Config()
360	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
361		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
362		model := cfg.GetModelByType(agentCfg.Model)
363		if providerCfg != nil && model != nil && model.CanReason {
364			selectedModel := cfg.Models[agentCfg.Model]
365
366			// Anthropic models: thinking toggle
367			if providerCfg.Type == catwalk.TypeAnthropic {
368				status := "Enable"
369				if selectedModel.Think {
370					status = "Disable"
371				}
372				commands = append(commands, uicmd.Command{
373					ID:          "toggle_thinking",
374					Title:       status + " Thinking Mode",
375					Description: "Toggle model thinking for reasoning-capable models",
376					Handler: func(cmd uicmd.Command) tea.Cmd {
377						return uiutil.CmdHandler(ToggleThinkingMsg{})
378					},
379				})
380			}
381
382			// OpenAI models: reasoning effort dialog
383			if len(model.ReasoningLevels) > 0 {
384				commands = append(commands, uicmd.Command{
385					ID:          "select_reasoning_effort",
386					Title:       "Select Reasoning Effort",
387					Description: "Choose reasoning effort level (low/medium/high)",
388					Handler: func(cmd uicmd.Command) tea.Cmd {
389						return uiutil.CmdHandler(OpenReasoningDialogMsg{})
390					},
391				})
392			}
393		}
394	}
395	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
396	// TODO: Get. Rid. Of. Magic. Numbers!
397	if c.width > 120 && c.sessionID != "" {
398		commands = append(commands, uicmd.Command{
399			ID:          "toggle_sidebar",
400			Title:       "Toggle Sidebar",
401			Description: "Toggle between compact and normal layout",
402			Handler: func(cmd uicmd.Command) tea.Cmd {
403				return uiutil.CmdHandler(ToggleCompactModeMsg{})
404			},
405		})
406	}
407	if c.sessionID != "" {
408		cfg := c.com.Config()
409		agentCfg := cfg.Agents[config.AgentCoder]
410		model := cfg.GetModelByType(agentCfg.Model)
411		if model.SupportsImages {
412			commands = append(commands, uicmd.Command{
413				ID:          "file_picker",
414				Title:       "Open File Picker",
415				Shortcut:    "ctrl+f",
416				Description: "Open file picker",
417				Handler: func(cmd uicmd.Command) tea.Cmd {
418					return uiutil.CmdHandler(OpenFilePickerMsg{})
419				},
420			})
421		}
422	}
423
424	// Add external editor command if $EDITOR is available
425	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
426	if os.Getenv("EDITOR") != "" {
427		commands = append(commands, uicmd.Command{
428			ID:          "open_external_editor",
429			Title:       "Open External Editor",
430			Shortcut:    "ctrl+o",
431			Description: "Open external editor to compose message",
432			Handler: func(cmd uicmd.Command) tea.Cmd {
433				return uiutil.CmdHandler(OpenExternalEditorMsg{})
434			},
435		})
436	}
437
438	return append(commands, []uicmd.Command{
439		{
440			ID:          "toggle_yolo",
441			Title:       "Toggle Yolo Mode",
442			Description: "Toggle yolo mode",
443			Handler: func(cmd uicmd.Command) tea.Cmd {
444				return uiutil.CmdHandler(ToggleYoloModeMsg{})
445			},
446		},
447		{
448			ID:          "toggle_help",
449			Title:       "Toggle Help",
450			Shortcut:    "ctrl+g",
451			Description: "Toggle help",
452			Handler: func(cmd uicmd.Command) tea.Cmd {
453				return uiutil.CmdHandler(ToggleHelpMsg{})
454			},
455		},
456		{
457			ID:          "init",
458			Title:       "Initialize Project",
459			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
460			Handler: func(cmd uicmd.Command) tea.Cmd {
461				initPrompt, err := agent.InitializePrompt(*c.com.Config())
462				if err != nil {
463					return uiutil.ReportError(err)
464				}
465				return uiutil.CmdHandler(chat.SendMsg{
466					Text: initPrompt,
467				})
468			},
469		},
470		{
471			ID:          "quit",
472			Title:       "Quit",
473			Description: "Quit",
474			Shortcut:    "ctrl+c",
475			Handler: func(cmd uicmd.Command) tea.Cmd {
476				return uiutil.CmdHandler(tea.QuitMsg{})
477			},
478		},
479	}...)
480}