1package dialog
2
3import (
4 "os"
5 "strings"
6
7 "charm.land/bubbles/v2/help"
8 "charm.land/bubbles/v2/key"
9 "charm.land/bubbles/v2/spinner"
10 "charm.land/bubbles/v2/textinput"
11 tea "charm.land/bubbletea/v2"
12 "github.com/charmbracelet/crush/internal/commands"
13 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/list"
16 "github.com/charmbracelet/crush/internal/ui/styles"
17 uv "github.com/charmbracelet/ultraviolet"
18)
19
20// CommandsID is the identifier for the commands dialog.
21const CommandsID = "commands"
22
23// CommandType represents the type of commands being displayed.
24type CommandType uint
25
26// String returns the string representation of the CommandType.
27func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
28
29const (
30 sidebarCompactModeBreakpoint = 120
31)
32
33const (
34 SystemCommands CommandType = iota
35 UserCommands
36 MCPPrompts
37)
38
39// Commands represents a dialog that shows available commands.
40type dockerMCPAvailabilityCheckedMsg struct {
41 available bool
42}
43
44type Commands struct {
45 com *common.Common
46 keyMap struct {
47 Select,
48 UpDown,
49 Next,
50 Previous,
51 Tab,
52 ShiftTab,
53 Close key.Binding
54 }
55
56 sessionID string
57 hasSession bool
58 hasTodos bool
59 hasQueue bool
60 selected CommandType
61
62 spinner spinner.Model
63 loading bool
64
65 help help.Model
66 input textinput.Model
67 list *list.FilterableList
68
69 windowWidth int
70
71 customCommands []commands.CustomCommand
72 mcpPrompts []commands.MCPPrompt
73
74 dockerMCPAvailable *bool
75 dockerMCPCheckInFlight bool
76}
77
78var _ Dialog = (*Commands)(nil)
79
80// NewCommands creates a new commands dialog.
81func NewCommands(com *common.Common, sessionID string, hasSession, hasTodos, hasQueue bool, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt) (*Commands, error) {
82 c := &Commands{
83 com: com,
84 selected: SystemCommands,
85 sessionID: sessionID,
86 hasSession: hasSession,
87 hasTodos: hasTodos,
88 hasQueue: hasQueue,
89 customCommands: customCommands,
90 mcpPrompts: mcpPrompts,
91 }
92
93 help := help.New()
94 help.Styles = com.Styles.DialogHelpStyles()
95
96 c.help = help
97
98 c.list = list.NewFilterableList()
99 c.list.Focus()
100 c.list.SetSelected(0)
101
102 c.input = textinput.New()
103 c.input.SetVirtualCursor(false)
104 c.input.Placeholder = "Type to filter"
105 c.input.SetStyles(com.Styles.TextInput)
106 c.input.Focus()
107
108 c.keyMap.Select = key.NewBinding(
109 key.WithKeys("enter", "ctrl+y"),
110 key.WithHelp("enter", "confirm"),
111 )
112 c.keyMap.UpDown = key.NewBinding(
113 key.WithKeys("up", "down"),
114 key.WithHelp("↑/↓", "choose"),
115 )
116 c.keyMap.Next = key.NewBinding(
117 key.WithKeys("down"),
118 key.WithHelp("↓", "next item"),
119 )
120 c.keyMap.Previous = key.NewBinding(
121 key.WithKeys("up", "ctrl+p"),
122 key.WithHelp("↑", "previous item"),
123 )
124 c.keyMap.Tab = key.NewBinding(
125 key.WithKeys("tab"),
126 key.WithHelp("tab", "switch selection"),
127 )
128 c.keyMap.ShiftTab = key.NewBinding(
129 key.WithKeys("shift+tab"),
130 key.WithHelp("shift+tab", "switch selection prev"),
131 )
132 closeKey := CloseKey
133 closeKey.SetHelp("esc", "cancel")
134 c.keyMap.Close = closeKey
135
136 if available, known := config.DockerMCPAvailabilityCached(); known {
137 c.dockerMCPAvailable = &available
138 }
139
140 // Set initial commands
141 c.setCommandItems(c.selected)
142
143 s := spinner.New()
144 s.Spinner = spinner.Dot
145 s.Style = com.Styles.Dialog.Spinner
146 c.spinner = s
147
148 return c, nil
149}
150
151// ID implements Dialog.
152func (c *Commands) ID() string {
153 return CommandsID
154}
155
156// HandleMsg implements [Dialog].
157func (c *Commands) HandleMsg(msg tea.Msg) Action {
158 switch msg := msg.(type) {
159 case dockerMCPAvailabilityCheckedMsg:
160 c.dockerMCPAvailable = &msg.available
161 c.dockerMCPCheckInFlight = false
162 if c.selected == SystemCommands {
163 c.setCommandItems(c.selected)
164 }
165 return nil
166 case spinner.TickMsg:
167 if c.loading {
168 var cmd tea.Cmd
169 c.spinner, cmd = c.spinner.Update(msg)
170 return ActionCmd{Cmd: cmd}
171 }
172 case tea.KeyPressMsg:
173 switch {
174 case key.Matches(msg, c.keyMap.Close):
175 return ActionClose{}
176 case key.Matches(msg, c.keyMap.Previous):
177 c.list.Focus()
178 if c.list.IsSelectedFirst() {
179 c.list.SelectLast()
180 } else {
181 c.list.SelectPrev()
182 }
183 c.list.ScrollToSelected()
184 case key.Matches(msg, c.keyMap.Next):
185 c.list.Focus()
186 if c.list.IsSelectedLast() {
187 c.list.SelectFirst()
188 } else {
189 c.list.SelectNext()
190 }
191 c.list.ScrollToSelected()
192 case key.Matches(msg, c.keyMap.Select):
193 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
194 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
195 return item.Action()
196 }
197 }
198 case key.Matches(msg, c.keyMap.Tab):
199 if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
200 c.selected = c.nextCommandType()
201 c.setCommandItems(c.selected)
202 }
203 case key.Matches(msg, c.keyMap.ShiftTab):
204 if len(c.customCommands) > 0 || len(c.mcpPrompts) > 0 {
205 c.selected = c.previousCommandType()
206 c.setCommandItems(c.selected)
207 }
208 default:
209 var cmd tea.Cmd
210 for _, item := range c.list.FilteredItems() {
211 if item, ok := item.(*CommandItem); ok && item != nil {
212 if msg.String() == item.Shortcut() {
213 return item.Action()
214 }
215 }
216 }
217 c.input, cmd = c.input.Update(msg)
218 value := c.input.Value()
219 c.list.SetFilter(value)
220 c.list.ScrollToTop()
221 c.list.SetSelected(0)
222 return ActionCmd{cmd}
223 }
224 }
225 return nil
226}
227
228func checkDockerMCPAvailabilityCmd() tea.Cmd {
229 return func() tea.Msg {
230 return dockerMCPAvailabilityCheckedMsg{available: config.RefreshDockerMCPAvailability()}
231 }
232}
233
234func (c *Commands) InitialCmd() tea.Cmd {
235 if c.dockerMCPAvailable != nil || c.dockerMCPCheckInFlight {
236 return nil
237 }
238 c.dockerMCPCheckInFlight = true
239 return checkDockerMCPAvailabilityCmd()
240}
241
242// Cursor returns the cursor position relative to the dialog.
243func (c *Commands) Cursor() *tea.Cursor {
244 return InputCursor(c.com.Styles, c.input.Cursor())
245}
246
247// commandsRadioView generates the command type selector radio buttons.
248func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
249 if !hasUserCmds && !hasMCPPrompts {
250 return ""
251 }
252
253 selectedFn := func(t CommandType) string {
254 if t == selected {
255 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
256 }
257 return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
258 }
259
260 parts := []string{
261 selectedFn(SystemCommands),
262 }
263
264 if hasUserCmds {
265 parts = append(parts, selectedFn(UserCommands))
266 }
267 if hasMCPPrompts {
268 parts = append(parts, selectedFn(MCPPrompts))
269 }
270
271 return strings.Join(parts, " ")
272}
273
274// Draw implements [Dialog].
275func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
276 t := c.com.Styles
277 width := max(0, min(defaultDialogMaxWidth, area.Dx()-t.Dialog.View.GetHorizontalBorderSize()))
278 height := max(0, min(defaultDialogHeight, area.Dy()-t.Dialog.View.GetVerticalBorderSize()))
279 if area.Dx() != c.windowWidth && c.selected == SystemCommands {
280 c.windowWidth = area.Dx()
281 // since some items in the list depend on width (e.g. toggle sidebar command),
282 // we need to reset the command items when width changes
283 c.setCommandItems(c.selected)
284 }
285
286 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
287 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
288 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
289 t.Dialog.HelpView.GetVerticalFrameSize() +
290 t.Dialog.View.GetVerticalFrameSize()
291
292 c.input.SetWidth(max(0, innerWidth-t.Dialog.InputPrompt.GetHorizontalFrameSize()-1)) // (1) cursor padding
293
294 c.list.SetSize(innerWidth, height-heightOffset)
295 c.help.SetWidth(innerWidth)
296
297 rc := NewRenderContext(t, width)
298 rc.Title = "Commands"
299 rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
300 inputView := t.Dialog.InputPrompt.Render(c.input.View())
301 rc.AddPart(inputView)
302 listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
303 rc.AddPart(listView)
304 rc.Help = c.help.View(c)
305
306 if c.loading {
307 rc.Help = c.spinner.View() + " Generating Prompt..."
308 }
309
310 view := rc.Render()
311
312 cur := c.Cursor()
313 DrawCenterCursor(scr, area, view, cur)
314 return cur
315}
316
317// ShortHelp implements [help.KeyMap].
318func (c *Commands) ShortHelp() []key.Binding {
319 return []key.Binding{
320 c.keyMap.Tab,
321 c.keyMap.UpDown,
322 c.keyMap.Select,
323 c.keyMap.Close,
324 }
325}
326
327// FullHelp implements [help.KeyMap].
328func (c *Commands) FullHelp() [][]key.Binding {
329 return [][]key.Binding{
330 {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
331 {c.keyMap.Close},
332 }
333}
334
335// nextCommandType returns the next command type in the cycle.
336func (c *Commands) nextCommandType() CommandType {
337 switch c.selected {
338 case SystemCommands:
339 if len(c.customCommands) > 0 {
340 return UserCommands
341 }
342 if len(c.mcpPrompts) > 0 {
343 return MCPPrompts
344 }
345 fallthrough
346 case UserCommands:
347 if len(c.mcpPrompts) > 0 {
348 return MCPPrompts
349 }
350 fallthrough
351 case MCPPrompts:
352 return SystemCommands
353 default:
354 return SystemCommands
355 }
356}
357
358// previousCommandType returns the previous command type in the cycle.
359func (c *Commands) previousCommandType() CommandType {
360 switch c.selected {
361 case SystemCommands:
362 if len(c.mcpPrompts) > 0 {
363 return MCPPrompts
364 }
365 if len(c.customCommands) > 0 {
366 return UserCommands
367 }
368 return SystemCommands
369 case UserCommands:
370 return SystemCommands
371 case MCPPrompts:
372 if len(c.customCommands) > 0 {
373 return UserCommands
374 }
375 return SystemCommands
376 default:
377 return SystemCommands
378 }
379}
380
381// setCommandItems sets the command items based on the specified command type.
382func (c *Commands) setCommandItems(commandType CommandType) {
383 c.selected = commandType
384
385 commandItems := []list.FilterableItem{}
386 switch c.selected {
387 case SystemCommands:
388 for _, cmd := range c.defaultCommands() {
389 commandItems = append(commandItems, cmd)
390 }
391 case UserCommands:
392 for _, cmd := range c.customCommands {
393 action := ActionRunCustomCommand{
394 Content: cmd.Content,
395 Arguments: cmd.Arguments,
396 }
397 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
398 }
399 case MCPPrompts:
400 for _, cmd := range c.mcpPrompts {
401 action := ActionRunMCPPrompt{
402 Title: cmd.Title,
403 Description: cmd.Description,
404 PromptID: cmd.PromptID,
405 ClientID: cmd.ClientID,
406 Arguments: cmd.Arguments,
407 }
408 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
409 }
410 }
411
412 c.list.SetItems(commandItems...)
413 c.list.SetFilter("")
414 c.list.ScrollToTop()
415 c.list.SetSelected(0)
416 c.input.SetValue("")
417}
418
419// defaultCommands returns the list of default system commands.
420func (c *Commands) defaultCommands() []*CommandItem {
421 commands := []*CommandItem{
422 NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
423 NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
424 NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
425 }
426
427 // Only show compact command if there's an active session
428 if c.hasSession {
429 commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
430 }
431
432 // Add reasoning toggle for models that support it
433 cfg := c.com.Config()
434 if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
435 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
436 model := cfg.GetModelByType(agentCfg.Model)
437 if providerCfg != nil && model != nil && model.CanReason {
438 selectedModel := cfg.Models[agentCfg.Model]
439
440 // Anthropic models: thinking toggle
441 if model.CanReason && len(model.ReasoningLevels) == 0 {
442 status := "Enable"
443 if selectedModel.Think {
444 status = "Disable"
445 }
446 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
447 }
448
449 // OpenAI models: reasoning effort dialog
450 if len(model.ReasoningLevels) > 0 {
451 commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
452 DialogID: ReasoningID,
453 }))
454 }
455 }
456 }
457 // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
458 if c.windowWidth >= sidebarCompactModeBreakpoint && c.hasSession {
459 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
460 }
461 if c.hasSession {
462 cfgPrime := c.com.Config()
463 agentCfg := cfgPrime.Agents[config.AgentCoder]
464 model := cfgPrime.GetModelByType(agentCfg.Model)
465 if model != nil && model.SupportsImages {
466 commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
467 DialogID: FilePickerID,
468 }))
469 }
470 }
471
472 // Add external editor command if $EDITOR is available.
473 //
474 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv;
475 // because os.Getenv does IO is breaks the TEA paradigm and is generally an
476 // antipattern.
477 if os.Getenv("EDITOR") != "" {
478 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
479 }
480
481 // Add Docker MCP command if available and not already enabled.
482 if !cfg.IsDockerMCPEnabled() && c.dockerMCPAvailable != nil && *c.dockerMCPAvailable {
483 commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
484 }
485
486 // Add disable Docker MCP command if it's currently enabled
487 if cfg.IsDockerMCPEnabled() {
488 commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
489 }
490
491 if c.hasTodos || c.hasQueue {
492 var label string
493 switch {
494 case c.hasTodos && c.hasQueue:
495 label = "Toggle To-Dos/Queue"
496 case c.hasQueue:
497 label = "Toggle Queue"
498 default:
499 label = "Toggle To-Dos"
500 }
501 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
502 }
503
504 // Add a command for toggling notifications.
505 cfg = c.com.Config()
506 notificationsDisabled := cfg != nil && cfg.Options != nil && cfg.Options.DisableNotifications
507 notificationLabel := "Disable Notifications"
508 if notificationsDisabled {
509 notificationLabel = "Enable Notifications"
510 }
511 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_notifications", notificationLabel, "", ActionToggleNotifications{}))
512
513 commands = append(commands,
514 NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
515 NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
516 NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
517 )
518
519 // Add transparent background toggle.
520 transparentLabel := "Disable Background Color"
521 if cfg != nil && cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent {
522 transparentLabel = "Enable Background Color"
523 }
524 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_transparent", transparentLabel, "", ActionToggleTransparentBackground{}))
525
526 commands = append(commands,
527 NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
528 )
529
530 return commands
531}
532
533// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
534func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
535 c.customCommands = customCommands
536 if c.selected == UserCommands {
537 c.setCommandItems(c.selected)
538 }
539}
540
541// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
542func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
543 c.mcpPrompts = mcpPrompts
544 if c.selected == MCPPrompts {
545 c.setCommandItems(c.selected)
546 }
547}
548
549// StartLoading implements [LoadingDialog].
550func (a *Commands) StartLoading() tea.Cmd {
551 if a.loading {
552 return nil
553 }
554 a.loading = true
555 return a.spinner.Tick
556}
557
558// StopLoading implements [LoadingDialog].
559func (a *Commands) StopLoading() {
560 a.loading = false
561}