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