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 defaultCommandsDialogMaxHeight = 20
32 defaultCommandsDialogMaxWidth = 70
33)
34
35const (
36 SystemCommands CommandType = iota
37 UserCommands
38 MCPPrompts
39)
40
41// Commands represents a dialog that shows available commands.
42type Commands struct {
43 com *common.Common
44 keyMap struct {
45 Select,
46 UpDown,
47 Next,
48 Previous,
49 Tab,
50 ShiftTab,
51 Close key.Binding
52 }
53
54 sessionID string // can be empty for non-session-specific commands
55 selected CommandType
56
57 spinner spinner.Model
58 loading bool
59
60 help help.Model
61 input textinput.Model
62 list *list.FilterableList
63
64 windowWidth int
65
66 customCommands []commands.CustomCommand
67 mcpPrompts []commands.MCPPrompt
68 isUserMessageSelected bool // whether a user message is selected in chat
69}
70
71var _ Dialog = (*Commands)(nil)
72
73// NewCommands creates a new commands dialog.
74func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpPrompts []commands.MCPPrompt, isUserMessageSelected bool) (*Commands, error) {
75 c := &Commands{
76 com: com,
77 selected: SystemCommands,
78 sessionID: sessionID,
79 customCommands: customCommands,
80 mcpPrompts: mcpPrompts,
81 isUserMessageSelected: isUserMessageSelected,
82 }
83
84 help := help.New()
85 help.Styles = com.Styles.DialogHelpStyles()
86
87 c.help = help
88
89 c.list = list.NewFilterableList()
90 c.list.Focus()
91 c.list.SetSelected(0)
92
93 c.input = textinput.New()
94 c.input.SetVirtualCursor(false)
95 c.input.Placeholder = "Type to filter"
96 c.input.SetStyles(com.Styles.TextInput)
97 c.input.Focus()
98
99 c.keyMap.Select = key.NewBinding(
100 key.WithKeys("enter", "ctrl+y"),
101 key.WithHelp("enter", "confirm"),
102 )
103 c.keyMap.UpDown = key.NewBinding(
104 key.WithKeys("up", "down"),
105 key.WithHelp("↑/↓", "choose"),
106 )
107 c.keyMap.Next = key.NewBinding(
108 key.WithKeys("down"),
109 key.WithHelp("↓", "next item"),
110 )
111 c.keyMap.Previous = key.NewBinding(
112 key.WithKeys("up", "ctrl+p"),
113 key.WithHelp("↑", "previous item"),
114 )
115 c.keyMap.Tab = key.NewBinding(
116 key.WithKeys("tab"),
117 key.WithHelp("tab", "switch selection"),
118 )
119 c.keyMap.ShiftTab = key.NewBinding(
120 key.WithKeys("shift+tab"),
121 key.WithHelp("shift+tab", "switch selection prev"),
122 )
123 closeKey := CloseKey
124 closeKey.SetHelp("esc", "cancel")
125 c.keyMap.Close = closeKey
126
127 // Set initial commands
128 c.setCommandItems(c.selected)
129
130 s := spinner.New()
131 s.Spinner = spinner.Dot
132 s.Style = com.Styles.Dialog.Spinner
133 c.spinner = s
134
135 return c, nil
136}
137
138// ID implements Dialog.
139func (c *Commands) ID() string {
140 return CommandsID
141}
142
143// HandleMsg implements [Dialog].
144func (c *Commands) HandleMsg(msg tea.Msg) Action {
145 switch msg := msg.(type) {
146 case spinner.TickMsg:
147 if c.loading {
148 var cmd tea.Cmd
149 c.spinner, cmd = c.spinner.Update(msg)
150 return ActionCmd{Cmd: cmd}
151 }
152 case tea.KeyPressMsg:
153 switch {
154 case key.Matches(msg, c.keyMap.Close):
155 return ActionClose{}
156 case key.Matches(msg, c.keyMap.Previous):
157 c.list.Focus()
158 if c.list.IsSelectedFirst() {
159 c.list.SelectLast()
160 c.list.ScrollToBottom()
161 break
162 }
163 c.list.SelectPrev()
164 c.list.ScrollToSelected()
165 case key.Matches(msg, c.keyMap.Next):
166 c.list.Focus()
167 if c.list.IsSelectedLast() {
168 c.list.SelectFirst()
169 c.list.ScrollToTop()
170 break
171 }
172 c.list.SelectNext()
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(defaultCommandsDialogMaxWidth, area.Dx()))
246 height := max(0, min(defaultCommandsDialogMaxHeight, area.Dy()))
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 }
392
393 if c.isUserMessageSelected {
394 commands = append(commands,
395 NewCommandItem(c.com.Styles, "fork_conversation", "Fork Conversation", "", ActionForkConversation{}),
396 NewCommandItem(c.com.Styles, "delete_messages", "Delete Bellow", "", ActionDeleteMessages{}),
397 )
398 }
399
400 commands = append(commands,
401 NewCommandItem(c.com.Styles, "switch_session", "Sessions", "ctrl+s", ActionOpenDialog{SessionsID}),
402 NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
403 )
404
405 // Only show compact command if there's an active session
406 if c.sessionID != "" {
407 commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
408 }
409
410 // Add reasoning toggle for models that support it
411 cfg := c.com.Config()
412 if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
413 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
414 model := cfg.GetModelByType(agentCfg.Model)
415 if providerCfg != nil && model != nil && model.CanReason {
416 selectedModel := cfg.Models[agentCfg.Model]
417
418 // Anthropic models: thinking toggle
419 if model.CanReason && len(model.ReasoningLevels) == 0 {
420 status := "Enable"
421 if selectedModel.Think {
422 status = "Disable"
423 }
424 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
425 }
426
427 // OpenAI models: reasoning effort dialog
428 if len(model.ReasoningLevels) > 0 {
429 commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
430 DialogID: ReasoningID,
431 }))
432 }
433 }
434 }
435 // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
436 if c.windowWidth >= sidebarCompactModeBreakpoint && c.sessionID != "" {
437 commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
438 }
439 if c.sessionID != "" {
440 cfg := c.com.Config()
441 agentCfg := cfg.Agents[config.AgentCoder]
442 model := cfg.GetModelByType(agentCfg.Model)
443 if model != nil && model.SupportsImages {
444 commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
445 // TODO: Pass in the file picker dialog id
446 }))
447 }
448 }
449
450 // Add external editor command if $EDITOR is available
451 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
452 if os.Getenv("EDITOR") != "" {
453 commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
454 }
455
456 return append(commands,
457 NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
458 NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
459 NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
460 NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
461 )
462}
463
464// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
465func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
466 c.customCommands = customCommands
467 if c.selected == UserCommands {
468 c.setCommandItems(c.selected)
469 }
470}
471
472// SetMCPPrompts sets the MCP prompts and refreshes the view if MCP prompts are currently displayed.
473func (c *Commands) SetMCPPrompts(mcpPrompts []commands.MCPPrompt) {
474 c.mcpPrompts = mcpPrompts
475 if c.selected == MCPPrompts {
476 c.setCommandItems(c.selected)
477 }
478}
479
480// StartLoading implements [LoadingDialog].
481func (a *Commands) StartLoading() tea.Cmd {
482 if a.loading {
483 return nil
484 }
485 a.loading = true
486 return a.spinner.Tick
487}
488
489// StopLoading implements [LoadingDialog].
490func (a *Commands) StopLoading() {
491 a.loading = false
492}