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