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