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