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