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