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