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