1package commands
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "slices"
9 "strings"
10
11 "charm.land/bubbles/v2/help"
12 "charm.land/bubbles/v2/key"
13 tea "charm.land/bubbletea/v2"
14 "charm.land/lipgloss/v2"
15 "github.com/charmbracelet/catwalk/pkg/catwalk"
16
17 "github.com/charmbracelet/crush/internal/agent"
18 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
19 "github.com/charmbracelet/crush/internal/config"
20 "github.com/charmbracelet/crush/internal/csync"
21 "github.com/charmbracelet/crush/internal/pubsub"
22 "github.com/charmbracelet/crush/internal/tui/components/chat"
23 "github.com/charmbracelet/crush/internal/tui/components/core"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
25 "github.com/charmbracelet/crush/internal/tui/exp/list"
26 "github.com/charmbracelet/crush/internal/tui/styles"
27 "github.com/charmbracelet/crush/internal/tui/util"
28)
29
30const (
31 CommandsDialogID dialogs.DialogID = "commands"
32
33 defaultWidth int = 70
34)
35
36type commandType uint
37
38func (c commandType) String() string { return []string{"System", "User", "MCP"}[c] }
39
40const (
41 SystemCommands commandType = iota
42 UserCommands
43 MCPPrompts
44)
45
46type listModel = list.FilterableList[list.CompletionItem[Command]]
47
48// Command represents a command that can be executed
49type Command struct {
50 ID string
51 Title string
52 Description string
53 Shortcut string // Optional shortcut for the command
54 Handler func(cmd Command) tea.Cmd
55}
56
57// CommandsDialog represents the commands dialog.
58type CommandsDialog interface {
59 dialogs.DialogModel
60}
61
62type commandDialogCmp struct {
63 width int
64 wWidth int // Width of the terminal window
65 wHeight int // Height of the terminal window
66
67 commandList listModel
68 keyMap CommandsDialogKeyMap
69 help help.Model
70 selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts
71 userCommands []Command // User-defined commands
72 mcpPrompts *csync.Slice[Command] // MCP prompts
73 sessionID string // Current session ID
74 ctx context.Context
75}
76
77type (
78 SwitchSessionsMsg struct{}
79 NewSessionsMsg struct{}
80 SwitchModelMsg struct{}
81 QuitMsg struct{}
82 OpenFilePickerMsg struct{}
83 ToggleHelpMsg struct{}
84 ToggleCompactModeMsg struct{}
85 ToggleThinkingMsg struct{}
86 OpenReasoningDialogMsg struct{}
87 OpenExternalEditorMsg struct{}
88 OpenEmbeddedEditorMsg struct {
89 FilePath string
90 Editor string
91 }
92 ToggleYoloModeMsg struct{}
93 OpenLazygitMsg struct{}
94 OpenGhDashMsg struct{}
95 CompactMsg struct {
96 SessionID string
97 }
98)
99
100func NewCommandDialog(ctx context.Context, sessionID string) CommandsDialog {
101 keyMap := DefaultCommandsDialogKeyMap()
102 listKeyMap := list.DefaultKeyMap()
103 listKeyMap.Down.SetEnabled(false)
104 listKeyMap.Up.SetEnabled(false)
105 listKeyMap.DownOneItem = keyMap.Next
106 listKeyMap.UpOneItem = keyMap.Previous
107
108 t := styles.CurrentTheme()
109 inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
110 commandList := list.NewFilterableList(
111 []list.CompletionItem[Command]{},
112 list.WithFilterInputStyle(inputStyle),
113 list.WithFilterListOptions(
114 list.WithKeyMap(listKeyMap),
115 list.WithWrapNavigation(),
116 list.WithResizeByList(),
117 ),
118 )
119 help := help.New()
120 help.Styles = t.S().Help
121 return &commandDialogCmp{
122 commandList: commandList,
123 width: defaultWidth,
124 keyMap: DefaultCommandsDialogKeyMap(),
125 help: help,
126 selected: SystemCommands,
127 sessionID: sessionID,
128 mcpPrompts: csync.NewSlice[Command](),
129 ctx: ctx,
130 }
131}
132
133func (c *commandDialogCmp) Init() tea.Cmd {
134 commands, err := LoadCustomCommands()
135 if err != nil {
136 return util.ReportError(err)
137 }
138 c.userCommands = commands
139 c.mcpPrompts.SetSlice(loadMCPPrompts())
140 return c.setCommandType(c.selected)
141}
142
143func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
144 switch msg := msg.(type) {
145 case tea.WindowSizeMsg:
146 c.wWidth = msg.Width
147 c.wHeight = msg.Height
148 return c, tea.Batch(
149 c.setCommandType(c.selected),
150 c.commandList.SetSize(c.listWidth(), c.listHeight()),
151 )
152 case pubsub.Event[mcp.Event]:
153 // Reload MCP prompts when MCP state changes
154 if msg.Type == pubsub.UpdatedEvent {
155 c.mcpPrompts.SetSlice(loadMCPPrompts())
156 // If we're currently viewing MCP prompts, refresh the list
157 if c.selected == MCPPrompts {
158 return c, c.setCommandType(MCPPrompts)
159 }
160 return c, nil
161 }
162 case tea.KeyPressMsg:
163 switch {
164 case key.Matches(msg, c.keyMap.Select):
165 selectedItem := c.commandList.SelectedItem()
166 if selectedItem == nil {
167 return c, nil // No item selected, do nothing
168 }
169 command := (*selectedItem).Value()
170 return c, tea.Sequence(
171 util.CmdHandler(dialogs.CloseDialogMsg{}),
172 command.Handler(command),
173 )
174 case key.Matches(msg, c.keyMap.Tab):
175 if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
176 return c, nil
177 }
178 return c, c.setCommandType(c.next())
179 case key.Matches(msg, c.keyMap.Close):
180 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
181 default:
182 u, cmd := c.commandList.Update(msg)
183 c.commandList = u.(listModel)
184 return c, cmd
185 }
186 }
187 return c, nil
188}
189
190func (c *commandDialogCmp) next() commandType {
191 switch c.selected {
192 case SystemCommands:
193 if len(c.userCommands) > 0 {
194 return UserCommands
195 }
196 if c.mcpPrompts.Len() > 0 {
197 return MCPPrompts
198 }
199 fallthrough
200 case UserCommands:
201 if c.mcpPrompts.Len() > 0 {
202 return MCPPrompts
203 }
204 fallthrough
205 case MCPPrompts:
206 return SystemCommands
207 default:
208 return SystemCommands
209 }
210}
211
212func (c *commandDialogCmp) View() string {
213 t := styles.CurrentTheme()
214 listView := c.commandList
215 radio := c.commandTypeRadio()
216
217 header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
218 if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
219 header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
220 }
221 content := lipgloss.JoinVertical(
222 lipgloss.Left,
223 header,
224 listView.View(),
225 "",
226 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
227 )
228 return c.style().Render(content)
229}
230
231func (c *commandDialogCmp) Cursor() *tea.Cursor {
232 if cursor, ok := c.commandList.(util.Cursor); ok {
233 cursor := cursor.Cursor()
234 if cursor != nil {
235 cursor = c.moveCursor(cursor)
236 }
237 return cursor
238 }
239 return nil
240}
241
242func (c *commandDialogCmp) commandTypeRadio() string {
243 t := styles.CurrentTheme()
244
245 fn := func(i commandType) string {
246 if i == c.selected {
247 return "◉ " + i.String()
248 }
249 return "○ " + i.String()
250 }
251
252 parts := []string{
253 fn(SystemCommands),
254 }
255 if len(c.userCommands) > 0 {
256 parts = append(parts, fn(UserCommands))
257 }
258 if c.mcpPrompts.Len() > 0 {
259 parts = append(parts, fn(MCPPrompts))
260 }
261 return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
262}
263
264func (c *commandDialogCmp) listWidth() int {
265 return defaultWidth - 2 // 4 for padding
266}
267
268func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
269 c.selected = commandType
270
271 var commands []Command
272 switch c.selected {
273 case SystemCommands:
274 commands = c.defaultCommands()
275 case UserCommands:
276 commands = c.userCommands
277 case MCPPrompts:
278 commands = slices.Collect(c.mcpPrompts.Seq())
279 }
280
281 commandItems := []list.CompletionItem[Command]{}
282 for _, cmd := range commands {
283 opts := []list.CompletionItemOption{
284 list.WithCompletionID(cmd.ID),
285 }
286 if cmd.Shortcut != "" {
287 opts = append(
288 opts,
289 list.WithCompletionShortcut(cmd.Shortcut),
290 )
291 }
292 commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
293 }
294 return c.commandList.SetItems(commandItems)
295}
296
297func (c *commandDialogCmp) listHeight() int {
298 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
299 return min(listHeigh, c.wHeight/2)
300}
301
302func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
303 row, col := c.Position()
304 offset := row + 3
305 cursor.Y += offset
306 cursor.X = cursor.X + col + 2
307 return cursor
308}
309
310func (c *commandDialogCmp) style() lipgloss.Style {
311 t := styles.CurrentTheme()
312 return t.S().Base.
313 Width(c.width).
314 Border(lipgloss.RoundedBorder()).
315 BorderForeground(t.BorderFocus)
316}
317
318func (c *commandDialogCmp) Position() (int, int) {
319 row := c.wHeight/4 - 2 // just a bit above the center
320 col := c.wWidth / 2
321 col -= c.width / 2
322 return row, col
323}
324
325func (c *commandDialogCmp) defaultCommands() []Command {
326 commands := []Command{
327 {
328 ID: "new_session",
329 Title: "New Session",
330 Description: "start a new session",
331 Shortcut: "ctrl+n",
332 Handler: func(cmd Command) tea.Cmd {
333 return util.CmdHandler(NewSessionsMsg{})
334 },
335 },
336 {
337 ID: "switch_session",
338 Title: "Switch Session",
339 Description: "Switch to a different session",
340 Shortcut: "ctrl+s",
341 Handler: func(cmd Command) tea.Cmd {
342 return util.CmdHandler(SwitchSessionsMsg{})
343 },
344 },
345 {
346 ID: "switch_model",
347 Title: "Switch Model",
348 Description: "Switch to a different model",
349 Shortcut: "ctrl+l",
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[config.AgentCoder]; 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 len(model.ReasoningLevels) > 0 {
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[config.AgentCoder]
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 // Add lazygit command if lazygit is installed.
448 if _, err := exec.LookPath("lazygit"); err == nil {
449 commands = append(commands, Command{
450 ID: "lazygit",
451 Title: "Open Lazygit",
452 Description: "Open lazygit for git operations",
453 Handler: func(cmd Command) tea.Cmd {
454 return util.CmdHandler(OpenLazygitMsg{})
455 },
456 })
457 }
458
459 // Add gh-dash command if gh CLI with dash extension is installed.
460 if out, err := exec.CommandContext(c.ctx, "gh", "extension", "list").Output(); err == nil {
461 if strings.Contains(string(out), "dash") {
462 commands = append(commands, Command{
463 ID: "ghdash",
464 Title: "Open GitHub Dashboard",
465 Description: "Open gh-dash for GitHub pull requests and issues",
466 Handler: func(cmd Command) tea.Cmd {
467 return util.CmdHandler(OpenGhDashMsg{})
468 },
469 })
470 }
471 }
472
473 return append(commands, []Command{
474 {
475 ID: "toggle_yolo",
476 Title: "Toggle Yolo Mode",
477 Description: "Toggle yolo mode",
478 Handler: func(cmd Command) tea.Cmd {
479 return util.CmdHandler(ToggleYoloModeMsg{})
480 },
481 },
482 {
483 ID: "toggle_help",
484 Title: "Toggle Help",
485 Shortcut: "ctrl+g",
486 Description: "Toggle help",
487 Handler: func(cmd Command) tea.Cmd {
488 return util.CmdHandler(ToggleHelpMsg{})
489 },
490 },
491 {
492 ID: "init",
493 Title: "Initialize Project",
494 Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
495 Handler: func(cmd Command) tea.Cmd {
496 initPrompt, err := agent.InitializePrompt(*config.Get())
497 if err != nil {
498 return util.ReportError(err)
499 }
500 return util.CmdHandler(chat.SendMsg{
501 Text: initPrompt,
502 })
503 },
504 },
505 {
506 ID: "quit",
507 Title: "Quit",
508 Description: "Quit",
509 Shortcut: "ctrl+c",
510 Handler: func(cmd Command) tea.Cmd {
511 return util.CmdHandler(QuitMsg{})
512 },
513 },
514 }...)
515}
516
517func (c *commandDialogCmp) ID() dialogs.DialogID {
518 return CommandsDialogID
519}