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