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}