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