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