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