commands.go

  1package commands
  2
  3import (
  4	"fmt"
  5	"os"
  6	"slices"
  7	"strings"
  8
  9	"charm.land/bubbles/v2/help"
 10	"charm.land/bubbles/v2/key"
 11	tea "charm.land/bubbletea/v2"
 12	"charm.land/lipgloss/v2"
 13	"github.com/charmbracelet/catwalk/pkg/catwalk"
 14
 15	"git.secluded.site/crush/internal/agent"
 16	"git.secluded.site/crush/internal/agent/tools/mcp"
 17	"git.secluded.site/crush/internal/config"
 18	"git.secluded.site/crush/internal/csync"
 19	"git.secluded.site/crush/internal/pubsub"
 20	"git.secluded.site/crush/internal/tui/components/chat"
 21	"git.secluded.site/crush/internal/tui/components/core"
 22	"git.secluded.site/crush/internal/tui/components/dialogs"
 23	"git.secluded.site/crush/internal/tui/exp/list"
 24	"git.secluded.site/crush/internal/tui/styles"
 25	"git.secluded.site/crush/internal/tui/util"
 26)
 27
 28const (
 29	CommandsDialogID dialogs.DialogID = "commands"
 30
 31	defaultWidth int = 70
 32)
 33
 34type commandType uint
 35
 36func (c commandType) String() string { return []string{"System", "User", "MCP"}[c] }
 37
 38const (
 39	SystemCommands commandType = iota
 40	UserCommands
 41	MCPPrompts
 42)
 43
 44type listModel = list.FilterableList[list.CompletionItem[Command]]
 45
 46// Command represents a command that can be executed
 47type Command struct {
 48	ID          string
 49	Title       string
 50	Description string
 51	Shortcut    string // Optional shortcut for the command
 52	Handler     func(cmd Command) tea.Cmd
 53}
 54
 55// CommandsDialog represents the commands dialog.
 56type CommandsDialog interface {
 57	dialogs.DialogModel
 58}
 59
 60type commandDialogCmp struct {
 61	width   int
 62	wWidth  int // Width of the terminal window
 63	wHeight int // Height of the terminal window
 64
 65	commandList  listModel
 66	keyMap       CommandsDialogKeyMap
 67	help         help.Model
 68	selected     commandType           // Selected SystemCommands, UserCommands, or MCPPrompts
 69	userCommands []Command             // User-defined commands
 70	mcpPrompts   *csync.Slice[Command] // MCP prompts
 71	sessionID    string                // Current session ID
 72}
 73
 74type (
 75	SwitchSessionsMsg      struct{}
 76	NewSessionsMsg         struct{}
 77	SwitchModelMsg         struct{}
 78	QuitMsg                struct{}
 79	OpenFilePickerMsg      struct{}
 80	ToggleHelpMsg          struct{}
 81	ToggleCompactModeMsg   struct{}
 82	ToggleThinkingMsg      struct{}
 83	OpenReasoningDialogMsg struct{}
 84	OpenExternalEditorMsg  struct{}
 85	ToggleYoloModeMsg      struct{}
 86	CompactMsg             struct {
 87		SessionID string
 88	}
 89)
 90
 91func NewCommandDialog(sessionID string) CommandsDialog {
 92	keyMap := DefaultCommandsDialogKeyMap()
 93	listKeyMap := list.DefaultKeyMap()
 94	listKeyMap.Down.SetEnabled(false)
 95	listKeyMap.Up.SetEnabled(false)
 96	listKeyMap.DownOneItem = keyMap.Next
 97	listKeyMap.UpOneItem = keyMap.Previous
 98
 99	t := styles.CurrentTheme()
100	inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
101	commandList := list.NewFilterableList(
102		[]list.CompletionItem[Command]{},
103		list.WithFilterInputStyle(inputStyle),
104		list.WithFilterListOptions(
105			list.WithKeyMap(listKeyMap),
106			list.WithWrapNavigation(),
107			list.WithResizeByList(),
108		),
109	)
110	help := help.New()
111	help.Styles = t.S().Help
112	return &commandDialogCmp{
113		commandList: commandList,
114		width:       defaultWidth,
115		keyMap:      DefaultCommandsDialogKeyMap(),
116		help:        help,
117		selected:    SystemCommands,
118		sessionID:   sessionID,
119		mcpPrompts:  csync.NewSlice[Command](),
120	}
121}
122
123func (c *commandDialogCmp) Init() tea.Cmd {
124	commands, err := LoadCustomCommands()
125	if err != nil {
126		return util.ReportError(err)
127	}
128	c.userCommands = commands
129	c.mcpPrompts.SetSlice(loadMCPPrompts())
130	return c.setCommandType(c.selected)
131}
132
133func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
134	switch msg := msg.(type) {
135	case tea.WindowSizeMsg:
136		c.wWidth = msg.Width
137		c.wHeight = msg.Height
138		return c, tea.Batch(
139			c.setCommandType(c.selected),
140			c.commandList.SetSize(c.listWidth(), c.listHeight()),
141		)
142	case pubsub.Event[mcp.Event]:
143		// Reload MCP prompts when MCP state changes
144		if msg.Type == pubsub.UpdatedEvent {
145			c.mcpPrompts.SetSlice(loadMCPPrompts())
146			// If we're currently viewing MCP prompts, refresh the list
147			if c.selected == MCPPrompts {
148				return c, c.setCommandType(MCPPrompts)
149			}
150			return c, nil
151		}
152	case tea.KeyPressMsg:
153		switch {
154		case key.Matches(msg, c.keyMap.Select):
155			selectedItem := c.commandList.SelectedItem()
156			if selectedItem == nil {
157				return c, nil // No item selected, do nothing
158			}
159			command := (*selectedItem).Value()
160			return c, tea.Sequence(
161				util.CmdHandler(dialogs.CloseDialogMsg{}),
162				command.Handler(command),
163			)
164		case key.Matches(msg, c.keyMap.Tab):
165			if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
166				return c, nil
167			}
168			return c, c.setCommandType(c.next())
169		case key.Matches(msg, c.keyMap.Close):
170			return c, util.CmdHandler(dialogs.CloseDialogMsg{})
171		default:
172			u, cmd := c.commandList.Update(msg)
173			c.commandList = u.(listModel)
174			return c, cmd
175		}
176	}
177	return c, nil
178}
179
180func (c *commandDialogCmp) next() commandType {
181	switch c.selected {
182	case SystemCommands:
183		if len(c.userCommands) > 0 {
184			return UserCommands
185		}
186		if c.mcpPrompts.Len() > 0 {
187			return MCPPrompts
188		}
189		fallthrough
190	case UserCommands:
191		if c.mcpPrompts.Len() > 0 {
192			return MCPPrompts
193		}
194		fallthrough
195	case MCPPrompts:
196		return SystemCommands
197	default:
198		return SystemCommands
199	}
200}
201
202func (c *commandDialogCmp) View() string {
203	t := styles.CurrentTheme()
204	listView := c.commandList
205	radio := c.commandTypeRadio()
206
207	header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
208	if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
209		header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
210	}
211	content := lipgloss.JoinVertical(
212		lipgloss.Left,
213		header,
214		listView.View(),
215		"",
216		t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
217	)
218	return c.style().Render(content)
219}
220
221func (c *commandDialogCmp) Cursor() *tea.Cursor {
222	if cursor, ok := c.commandList.(util.Cursor); ok {
223		cursor := cursor.Cursor()
224		if cursor != nil {
225			cursor = c.moveCursor(cursor)
226		}
227		return cursor
228	}
229	return nil
230}
231
232func (c *commandDialogCmp) commandTypeRadio() string {
233	t := styles.CurrentTheme()
234
235	fn := func(i commandType) string {
236		if i == c.selected {
237			return "◉ " + i.String()
238		}
239		return "○ " + i.String()
240	}
241
242	parts := []string{
243		fn(SystemCommands),
244	}
245	if len(c.userCommands) > 0 {
246		parts = append(parts, fn(UserCommands))
247	}
248	if c.mcpPrompts.Len() > 0 {
249		parts = append(parts, fn(MCPPrompts))
250	}
251	return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
252}
253
254func (c *commandDialogCmp) listWidth() int {
255	return defaultWidth - 2 // 4 for padding
256}
257
258func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
259	c.selected = commandType
260
261	var commands []Command
262	switch c.selected {
263	case SystemCommands:
264		commands = c.defaultCommands()
265	case UserCommands:
266		commands = c.userCommands
267	case MCPPrompts:
268		commands = slices.Collect(c.mcpPrompts.Seq())
269	}
270
271	commandItems := []list.CompletionItem[Command]{}
272	for _, cmd := range commands {
273		opts := []list.CompletionItemOption{
274			list.WithCompletionID(cmd.ID),
275		}
276		if cmd.Shortcut != "" {
277			opts = append(
278				opts,
279				list.WithCompletionShortcut(cmd.Shortcut),
280			)
281		}
282		commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
283	}
284	return c.commandList.SetItems(commandItems)
285}
286
287func (c *commandDialogCmp) listHeight() int {
288	listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
289	return min(listHeigh, c.wHeight/2)
290}
291
292func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
293	row, col := c.Position()
294	offset := row + 3
295	cursor.Y += offset
296	cursor.X = cursor.X + col + 2
297	return cursor
298}
299
300func (c *commandDialogCmp) style() lipgloss.Style {
301	t := styles.CurrentTheme()
302	return t.S().Base.
303		Width(c.width).
304		Border(lipgloss.RoundedBorder()).
305		BorderForeground(t.BorderFocus)
306}
307
308func (c *commandDialogCmp) Position() (int, int) {
309	row := c.wHeight/4 - 2 // just a bit above the center
310	col := c.wWidth / 2
311	col -= c.width / 2
312	return row, col
313}
314
315func (c *commandDialogCmp) defaultCommands() []Command {
316	commands := []Command{
317		{
318			ID:          "new_session",
319			Title:       "New Session",
320			Description: "start a new session",
321			Shortcut:    "ctrl+n",
322			Handler: func(cmd Command) tea.Cmd {
323				return util.CmdHandler(NewSessionsMsg{})
324			},
325		},
326		{
327			ID:          "switch_session",
328			Title:       "Switch Session",
329			Description: "Switch to a different session",
330			Shortcut:    "ctrl+s",
331			Handler: func(cmd Command) tea.Cmd {
332				return util.CmdHandler(SwitchSessionsMsg{})
333			},
334		},
335		{
336			ID:          "switch_model",
337			Title:       "Switch Model",
338			Description: "Switch to a different model",
339			Shortcut:    "ctrl+l",
340			Handler: func(cmd Command) tea.Cmd {
341				return util.CmdHandler(SwitchModelMsg{})
342			},
343		},
344	}
345
346	// Only show compact command if there's an active session
347	if c.sessionID != "" {
348		commands = append(commands, Command{
349			ID:          "Summarize",
350			Title:       "Summarize Session",
351			Description: "Summarize the current session and create a new one with the summary",
352			Handler: func(cmd Command) tea.Cmd {
353				return util.CmdHandler(CompactMsg{
354					SessionID: c.sessionID,
355				})
356			},
357		})
358	}
359
360	// Add reasoning toggle for models that support it
361	cfg := config.Get()
362	if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
363		providerCfg := cfg.GetProviderForModel(agentCfg.Model)
364		model := cfg.GetModelByType(agentCfg.Model)
365		if providerCfg != nil && model != nil && model.CanReason {
366			selectedModel := cfg.Models[agentCfg.Model]
367
368			// Anthropic models: thinking toggle
369			if providerCfg.Type == catwalk.TypeAnthropic {
370				status := "Enable"
371				if selectedModel.Think {
372					status = "Disable"
373				}
374				commands = append(commands, Command{
375					ID:          "toggle_thinking",
376					Title:       status + " Thinking Mode",
377					Description: "Toggle model thinking for reasoning-capable models",
378					Handler: func(cmd Command) tea.Cmd {
379						return util.CmdHandler(ToggleThinkingMsg{})
380					},
381				})
382			}
383
384			// OpenAI models: reasoning effort dialog
385			if len(model.ReasoningLevels) > 0 {
386				commands = append(commands, Command{
387					ID:          "select_reasoning_effort",
388					Title:       "Select Reasoning Effort",
389					Description: "Choose reasoning effort level (low/medium/high)",
390					Handler: func(cmd Command) tea.Cmd {
391						return util.CmdHandler(OpenReasoningDialogMsg{})
392					},
393				})
394			}
395		}
396	}
397	// Only show toggle compact mode command if window width is larger than compact breakpoint (90)
398	if c.wWidth > 120 && c.sessionID != "" {
399		commands = append(commands, Command{
400			ID:          "toggle_sidebar",
401			Title:       "Toggle Sidebar",
402			Description: "Toggle between compact and normal layout",
403			Handler: func(cmd Command) tea.Cmd {
404				return util.CmdHandler(ToggleCompactModeMsg{})
405			},
406		})
407	}
408	if c.sessionID != "" {
409		agentCfg := config.Get().Agents[config.AgentCoder]
410		model := config.Get().GetModelByType(agentCfg.Model)
411		if model.SupportsImages {
412			commands = append(commands, Command{
413				ID:          "file_picker",
414				Title:       "Open File Picker",
415				Shortcut:    "ctrl+f",
416				Description: "Open file picker",
417				Handler: func(cmd Command) tea.Cmd {
418					return util.CmdHandler(OpenFilePickerMsg{})
419				},
420			})
421		}
422	}
423
424	// Add external editor command if $EDITOR is available
425	if os.Getenv("EDITOR") != "" {
426		commands = append(commands, Command{
427			ID:          "open_external_editor",
428			Title:       "Open External Editor",
429			Shortcut:    "ctrl+o",
430			Description: "Open external editor to compose message",
431			Handler: func(cmd Command) tea.Cmd {
432				return util.CmdHandler(OpenExternalEditorMsg{})
433			},
434		})
435	}
436
437	return append(commands, []Command{
438		{
439			ID:          "toggle_yolo",
440			Title:       "Toggle Yolo Mode",
441			Description: "Toggle yolo mode",
442			Handler: func(cmd Command) tea.Cmd {
443				return util.CmdHandler(ToggleYoloModeMsg{})
444			},
445		},
446		{
447			ID:          "toggle_help",
448			Title:       "Toggle Help",
449			Shortcut:    "ctrl+g",
450			Description: "Toggle help",
451			Handler: func(cmd Command) tea.Cmd {
452				return util.CmdHandler(ToggleHelpMsg{})
453			},
454		},
455		{
456			ID:          "init",
457			Title:       "Initialize Project",
458			Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
459			Handler: func(cmd Command) tea.Cmd {
460				initPrompt, err := agent.InitializePrompt(*config.Get())
461				if err != nil {
462					return util.ReportError(err)
463				}
464				return util.CmdHandler(chat.SendMsg{
465					Text: initPrompt,
466				})
467			},
468		},
469		{
470			ID:          "quit",
471			Title:       "Quit",
472			Description: "Quit",
473			Shortcut:    "ctrl+c",
474			Handler: func(cmd Command) tea.Cmd {
475				return util.CmdHandler(QuitMsg{})
476			},
477		},
478	}...)
479}
480
481func (c *commandDialogCmp) ID() dialogs.DialogID {
482	return CommandsDialogID
483}