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