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