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