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.Radio.On.Padding(0, 1).Render() + sty.Radio.Label.Render(t.String())
256 }
257 return sty.Radio.Off.Padding(0, 1).Render() + sty.Radio.Label.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 var action Action
394 if cmd.Skill != nil {
395 action = ActionAttachSkill{ID: cmd.Skill.SkillFilePath, Name: cmd.Skill.Name}
396 } else {
397 action = ActionRunCustomCommand{
398 Content: cmd.Content,
399 Arguments: cmd.Arguments,
400 Skill: cmd.Skill,
401 }
402 }
403 item := NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action)
404 if cmd.Skill != nil {
405 item = item.WithDescription(cmd.Skill.Description)
406 }
407 commandItems = append(commandItems, item)
408 }
409 case MCPPrompts:
410 for _, cmd := range c.mcpPrompts {
411 action := ActionRunMCPPrompt{
412 Title: cmd.Title,
413 Description: cmd.Description,
414 PromptID: cmd.PromptID,
415 ClientID: cmd.ClientID,
416 Arguments: cmd.Arguments,
417 }
418 commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.PromptID, "", action))
419 }
420 }
421
422 c.list.SetItems(commandItems...)
423 c.list.SetFilter("")
424 c.list.ScrollToTop()
425 c.list.SetSelected(0)
426 c.input.SetValue("")
427}
428
429// defaultCommands returns the list of default system commands.
430func (c *Commands) defaultCommands() []*CommandItem {
431 commands := []*CommandItem{
432 NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
433 NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
434 NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
435 }
436
437 // Only show compact command if there's an active session
438 if c.hasSession {
439 commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
440 }
441
442 // Add reasoning toggle for models that support it
443 cfg := c.com.Config()
444 if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
445 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
446 model := cfg.GetModelByType(agentCfg.Model)
447 if providerCfg != nil && model != nil && model.CanReason {
448 selectedModel := cfg.Models[agentCfg.Model]
449
450 // Anthropic models: thinking toggle
451 if model.CanReason && len(model.ReasoningLevels) == 0 {
452 status := "Enable"
453 if selectedModel.Think {
454 status = "Disable"
455 }
456 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
457 }
458
459 // OpenAI models: reasoning effort dialog
460 if len(model.ReasoningLevels) > 0 {
461 commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
462 DialogID: ReasoningID,
463 }))
464 }
465 }
466 }
467 // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
468 if c.windowWidth >= sidebarCompactModeBreakpoint && c.hasSession {
469 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
470 }
471 if c.hasSession {
472 cfgPrime := c.com.Config()
473 agentCfg := cfgPrime.Agents[config.AgentCoder]
474 model := cfgPrime.GetModelByType(agentCfg.Model)
475 if model != nil && model.SupportsImages {
476 commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
477 DialogID: FilePickerID,
478 }))
479 }
480 }
481
482 // Add external editor command if $EDITOR is available.
483 //
484 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv;
485 // because os.Getenv does IO is breaks the TEA paradigm and is generally an
486 // antipattern.
487 if os.Getenv("EDITOR") != "" {
488 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
489 }
490
491 // Add Docker MCP command if available and not already enabled.
492 if !cfg.IsDockerMCPEnabled() && c.dockerMCPAvailable != nil && *c.dockerMCPAvailable {
493 commands = append(commands, NewCommandItem(c.com.Styles, "enable_docker_mcp", "Enable Docker MCP Catalog", "", ActionEnableDockerMCP{}))
494 }
495
496 // Add disable Docker MCP command if it's currently enabled
497 if cfg.IsDockerMCPEnabled() {
498 commands = append(commands, NewCommandItem(c.com.Styles, "disable_docker_mcp", "Disable Docker MCP Catalog", "", ActionDisableDockerMCP{}))
499 }
500
501 if c.hasTodos || c.hasQueue {
502 var label string
503 switch {
504 case c.hasTodos && c.hasQueue:
505 label = "Toggle To-Dos/Queue"
506 case c.hasQueue:
507 label = "Toggle Queue"
508 default:
509 label = "Toggle To-Dos"
510 }
511 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_pills", label, "ctrl+t", ActionTogglePills{}))
512 }
513
514 // Add a command for selecting notification style via picker dialog.
515 notificationLabel := "Notification Style"
516 commands = append(commands, NewCommandItem(c.com.Styles, "select_notifications", notificationLabel, "", ActionOpenDialog{DialogID: NotificationsID}))
517
518 commands = append(
519 commands,
520 NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "ctrl+y", ActionToggleYoloMode{}),
521 NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
522 NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
523 )
524
525 // Add transparent background toggle.
526 transparentLabel := "Disable Background Color"
527 if cfg != nil && cfg.Options != nil && cfg.Options.TUI.Transparent != nil && *cfg.Options.TUI.Transparent {
528 transparentLabel = "Enable Background Color"
529 }
530 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_transparent", transparentLabel, "", ActionToggleTransparentBackground{}))
531
532 commands = append(
533 commands,
534 NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}).WithAliases("exit"),
535 )
536
537 return commands
538}
539
540// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
541func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
542 c.customCommands = customCommands
543 if c.selected == UserCommands {
544 c.setCommandItems(c.selected)
545 }
546}
547
548// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
549func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
550 c.mcpPrompts = mcpPrompts
551 if c.selected == MCPPrompts {
552 c.setCommandItems(c.selected)
553 }
554}
555
556// StartLoading implements [LoadingDialog].
557func (a *Commands) StartLoading() tea.Cmd {
558 if a.loading {
559 return nil
560 }
561 a.loading = true
562 return a.spinner.Tick
563}
564
565// StopLoading implements [LoadingDialog].
566func (a *Commands) StopLoading() {
567 a.loading = false
568}