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