commands.go

  1package dialog
  2
  3import (
  4	"os"
  5	"strings"
  6
  7	"charm.land/bubbles/v2/help"
  8	"charm.land/bubbles/v2/key"
  9	"charm.land/bubbles/v2/spinner"
 10	"charm.land/bubbles/v2/textinput"
 11	tea "charm.land/bubbletea/v2"
 12	"git.secluded.site/crush/internal/commands"
 13	"git.secluded.site/crush/internal/config"
 14	"git.secluded.site/crush/internal/ui/common"
 15	"git.secluded.site/crush/internal/ui/list"
 16	"git.secluded.site/crush/internal/ui/styles"
 17	uv "github.com/charmbracelet/ultraviolet"
 18)
 19
 20// CommandsID is the identifier for the commands dialog.
 21const CommandsID = "commands"
 22
 23// CommandType represents the type of commands being displayed.
 24type CommandType uint
 25
 26// String returns the string representation of the CommandType.
 27func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
 28
 29const (
 30	sidebarCompactModeBreakpoint   = 120
 31	defaultCommandsDialogMaxHeight = 20
 32	defaultCommandsDialogMaxWidth  = 70
 33)
 34
 35const (
 36	SystemCommands CommandType = iota
 37	UserCommands
 38	MCPPrompts
 39)
 40
 41// Commands represents a dialog that shows available commands.
 42type Commands struct {
 43	com    *common.Common
 44	keyMap struct {
 45		Select,
 46		UpDown,
 47		Next,
 48		Previous,
 49		Tab,
 50		ShiftTab,
 51		Close key.Binding
 52	}
 53
 54	sessionID string // can be empty for non-session-specific commands
 55	selected  CommandType
 56
 57	spinner spinner.Model
 58	loading bool
 59
 60	help  help.Model
 61	input textinput.Model
 62	list  *list.FilterableList
 63
 64	windowWidth int
 65
 66	customCommands []commands.CustomCommand
 67	mcpPrompts     []commands.MCPPrompt
 68}
 69
 70var _ Dialog = (*Commands)(nil)
 71
 72// NewCommands creates a new commands dialog.
 73func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
 74	c := &Commands{
 75		com:            com,
 76		selected:       SystemCommands,
 77		sessionID:      sessionID,
 78		customCommands: customCommands,
 79		mcpPrompts:     mcpPrompts,
 80	}
 81
 82	help := help.New()
 83	help.Styles = com.Styles.DialogHelpStyles()
 84
 85	c.help = help
 86
 87	c.list = list.NewFilterableList()
 88	c.list.Focus()
 89	c.list.SetSelected(0)
 90
 91	c.input = textinput.New()
 92	c.input.SetVirtualCursor(false)
 93	c.input.Placeholder = "Type to filter"
 94	c.input.SetStyles(com.Styles.TextInput)
 95	c.input.Focus()
 96
 97	c.keyMap.Select = key.NewBinding(
 98		key.WithKeys("enter", "ctrl+y"),
 99		key.WithHelp("enter", "confirm"),
100	)
101	c.keyMap.UpDown = key.NewBinding(
102		key.WithKeys("up", "down"),
103		key.WithHelp("↑/↓", "choose"),
104	)
105	c.keyMap.Next = key.NewBinding(
106		key.WithKeys("down"),
107		key.WithHelp("↓", "next item"),
108	)
109	c.keyMap.Previous = key.NewBinding(
110		key.WithKeys("up", "ctrl+p"),
111		key.WithHelp("↑", "previous item"),
112	)
113	c.keyMap.Tab = key.NewBinding(
114		key.WithKeys("tab"),
115		key.WithHelp("tab", "switch selection"),
116	)
117	c.keyMap.ShiftTab = key.NewBinding(
118		key.WithKeys("shift+tab"),
119		key.WithHelp("shift+tab", "switch selection prev"),
120	)
121	closeKey := CloseKey
122	closeKey.SetHelp("esc", "cancel")
123	c.keyMap.Close = closeKey
124
125	// Set initial commands
126	c.setCommandItems(c.selected)
127
128	s := spinner.New()
129	s.Spinner = spinner.Dot
130	s.Style = com.Styles.Dialog.Spinner
131	c.spinner = s
132
133	return c, nil
134}
135
136// ID implements Dialog.
137func (c *Commands) ID() string {
138	return CommandsID
139}
140
141// HandleMsg implements [Dialog].
142func (c *Commands) HandleMsg(msg tea.Msg) Action {
143	switch msg := msg.(type) {
144	case spinner.TickMsg:
145		if c.loading {
146			var cmd tea.Cmd
147			c.spinner, cmd = c.spinner.Update(msg)
148			return ActionCmd{Cmd: cmd}
149		}
150	case tea.KeyPressMsg:
151		switch {
152		case key.Matches(msg, c.keyMap.Close):
153			return ActionClose{}
154		case key.Matches(msg, c.keyMap.Previous):
155			c.list.Focus()
156			if c.list.IsSelectedFirst() {
157				c.list.SelectLast()
158				c.list.ScrollToBottom()
159				break
160			}
161			c.list.SelectPrev()
162			c.list.ScrollToSelected()
163		case key.Matches(msg, c.keyMap.Next):
164			c.list.Focus()
165			if c.list.IsSelectedLast() {
166				c.list.SelectFirst()
167				c.list.ScrollToTop()
168				break
169			}
170			c.list.SelectNext()
171			c.list.ScrollToSelected()
172		case key.Matches(msg, c.keyMap.Select):
173			if selectedItem := c.list.SelectedItem(); selectedItem != nil {
174				if item, ok := selectedItem.(*CommandItem); ok && item != nil {
175					return item.Action()
176				}
177			}
178		case key.Matches(msg, c.keyMap.Tab):
179			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
180				c.selected = c.nextCommandType()
181				c.setCommandItems(c.selected)
182			}
183		case key.Matches(msg, c.keyMap.ShiftTab):
184			if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
185				c.selected = c.previousCommandType()
186				c.setCommandItems(c.selected)
187			}
188		default:
189			var cmd tea.Cmd
190			for _, item := range c.list.FilteredItems() {
191				if item, ok := item.(*CommandItem); ok && item != nil {
192					if msg.String() == item.Shortcut() {
193						return item.Action()
194					}
195				}
196			}
197			c.input, cmd = c.input.Update(msg)
198			value := c.input.Value()
199			c.list.SetFilter(value)
200			c.list.ScrollToTop()
201			c.list.SetSelected(0)
202			return ActionCmd{cmd}
203		}
204	}
205	return nil
206}
207
208// Cursor returns the cursor position relative to the dialog.
209func (c *Commands) Cursor() *tea.Cursor {
210	return InputCursor(c.com.Styles, c.input.Cursor())
211}
212
213// commandsRadioView generates the command type selector radio buttons.
214func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
215	if !hasUserCmds && !hasMCPPrompts {
216		return ""
217	}
218
219	selectedFn := func(t CommandType) string {
220		if t == selected {
221			return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
222		}
223		return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
224	}
225
226	parts := []string{
227		selectedFn(SystemCommands),
228	}
229
230	if hasUserCmds {
231		parts = append(parts, selectedFn(UserCommands))
232	}
233	if hasMCPPrompts {
234		parts = append(parts, selectedFn(MCPPrompts))
235	}
236
237	return strings.Join(parts, " ")
238}
239
240// Draw implements [Dialog].
241func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
242	t := c.com.Styles
243	width := max(0, min(defaultCommandsDialogMaxWidth, area.Dx()))
244	height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()))
245	if area.Dx() != c.windowWidth && c.selected == SystemCommands {
246		c.windowWidth = area.Dx()
247		// since some items in the list depend on width (e.g. toggle sidebar command),
248		// we need to reset the command items when width changes
249		c.setCommandItems(c.selected)
250	}
251
252	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
253	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
254		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
255		t.Dialog.HelpView.GetVerticalFrameSize() +
256		t.Dialog.View.GetVerticalFrameSize()
257
258	c.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
259
260	c.list.SetSize(innerWidth, height-heightOffset)
261	c.help.SetWidth(innerWidth)
262
263	rc := NewRenderContext(t, width)
264	rc.Title = "Commands"
265	rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
266	inputView := t.Dialog.InputPrompt.Render(c.input.View())
267	rc.AddPart(inputView)
268	listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
269	rc.AddPart(listView)
270	rc.Help = c.help.View(c)
271
272	if c.loading {
273		rc.Help = c.spinner.View() + " Generating Prompt..."
274	}
275
276	view := rc.Render()
277
278	cur := c.Cursor()
279	DrawCenterCursor(scr, area, view, cur)
280	return cur
281}
282
283// ShortHelp implements [help.KeyMap].
284func (c *Commands) ShortHelp() []key.Binding {
285	return []key.Binding{
286		c.keyMap.Tab,
287		c.keyMap.UpDown,
288		c.keyMap.Select,
289		c.keyMap.Close,
290	}
291}
292
293// FullHelp implements [help.KeyMap].
294func (c *Commands) FullHelp() [][]key.Binding {
295	return [][]key.Binding{
296		{c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
297		{c.keyMap.Close},
298	}
299}
300
301// nextCommandType returns the next command type in the cycle.
302func (c *Commands) nextCommandType() CommandType {
303	switch c.selected {
304	case SystemCommands:
305		if len(c.customCommands) > 0 {
306			return UserCommands
307		}
308		if len(c.mcpPrompts) > 0 {
309			return MCPPrompts
310		}
311		fallthrough
312	case UserCommands:
313		if len(c.mcpPrompts) > 0 {
314			return MCPPrompts
315		}
316		fallthrough
317	case MCPPrompts:
318		return SystemCommands
319	default:
320		return SystemCommands
321	}
322}
323
324// previousCommandType returns the previous command type in the cycle.
325func (c *Commands) previousCommandType() CommandType {
326	switch c.selected {
327	case SystemCommands:
328		if len(c.mcpPrompts) > 0 {
329			return MCPPrompts
330		}
331		if len(c.customCommands) > 0 {
332			return UserCommands
333		}
334		return SystemCommands
335	case UserCommands:
336		return SystemCommands
337	case MCPPrompts:
338		if len(c.customCommands) > 0 {
339			return UserCommands
340		}
341		return SystemCommands
342	default:
343		return SystemCommands
344	}
345}
346
347// setCommandItems sets the command items based on the specified command type.
348func (c *Commands) setCommandItems(commandType CommandType) {
349	c.selected = commandType
350
351	commandItems := []list.FilterableItem{}
352	switch c.selected {
353	case SystemCommands:
354		for _, cmd := range c.defaultCommands() {
355			commandItems = append(commandItems, cmd)
356		}
357	case UserCommands:
358		for _, cmd := range c.customCommands {
359			action := ActionRunCustomCommand{
360				Content:   cmd.Content,
361				Arguments: cmd.Arguments,
362			}
363			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
364		}
365	case MCPPrompts:
366		for _, cmd := range c.mcpPrompts {
367			action := ActionRunMCPPrompt{
368				Title:       cmd.Title,
369				Description: cmd.Description,
370				PromptID:    cmd.PromptID,
371				ClientID:    cmd.ClientID,
372				Arguments:   cmd.Arguments,
373			}
374			commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
375		}
376	}
377
378	c.list.SetItems(commandItems...)
379	c.list.SetFilter("")
380	c.list.ScrollToTop()
381	c.list.SetSelected(0)
382	c.input.SetValue("")
383}
384
385// defaultCommands returns the list of default system commands.
386func (c *Commands) defaultCommands() []*CommandItem {
387	commands := []*CommandItem{
388		NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
389		NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
390		NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
391	}
392
393	// Only show compact command if there's an active session
394	if c.sessionID != "" {
395		commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
396	}
397
398	// Add reasoning toggle for models that support it
399	cfg := c.com.Config()
400	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
401		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
402		model := cfg.GetModelByType(agentCfg.Model)
403		if providerCfg != nil && model != nil && model.CanReason {
404			selectedModel := cfg.Models[agentCfg.Model]
405
406			// Anthropic models: thinking toggle
407			if model.CanReason && len(model.ReasoningLevels) == 0 {
408				status := "Enable"
409				if selectedModel.Think {
410					status = "Disable"
411				}
412				commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
413			}
414
415			// OpenAI models: reasoning effort dialog
416			if len(model.ReasoningLevels) > 0 {
417				commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
418					DialogID: ReasoningID,
419				}))
420			}
421		}
422	}
423	// Only show toggle compact mode command if window width is larger than compact breakpoint (120)
424	if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" {
425		commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
426	}
427	if c.sessionID != "" {
428		cfg := c.com.Config()
429		agentCfg := cfg.Agents[config.AgentCoder]
430		model := cfg.GetModelByType(agentCfg.Model)
431		if model != nil && model.SupportsImages {
432			commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
433				// TODO: Pass in the file picker dialog id
434			}))
435		}
436	}
437
438	// Add external editor command if $EDITOR is available
439	// TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
440	if os.Getenv("EDITOR") != "" {
441		commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
442	}
443
444	return append(commands,
445		NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
446		NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
447		NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
448		NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
449	)
450}
451
452// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
453func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
454	c.customCommands = customCommands
455	if c.selected == UserCommands {
456		c.setCommandItems(c.selected)
457	}
458}
459
460// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
461func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
462	c.mcpPrompts = mcpPrompts
463	if c.selected == MCPPrompts {
464		c.setCommandItems(c.selected)
465	}
466}
467
468// StartLoading implements [LoadingDialog].
469func (a *Commands) StartLoading() tea.Cmd {
470	if a.loading {
471		return nil
472	}
473	a.loading = true
474	return a.spinner.Tick
475}
476
477// StopLoading implements [LoadingDialog].
478func (a *Commands) StopLoading() {
479	a.loading = false
480}