1package dialog
2
3import (
4 "fmt"
5 "os"
6 "slices"
7 "strings"
8
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/key"
11 "charm.land/bubbles/v2/textinput"
12 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
15 "github.com/charmbracelet/crush/internal/agent"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/csync"
18 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/util"
20 "github.com/charmbracelet/crush/internal/ui/common"
21 "github.com/charmbracelet/crush/internal/ui/list"
22 "github.com/charmbracelet/crush/internal/uicmd"
23)
24
25// CommandsID is the identifier for the commands dialog.
26const CommandsID = "commands"
27
28// Messages for commands
29type (
30 SwitchSessionsMsg struct{}
31 NewSessionsMsg struct{}
32 SwitchModelMsg struct{}
33 QuitMsg struct{}
34 OpenFilePickerMsg struct{}
35 ToggleHelpMsg struct{}
36 ToggleCompactModeMsg struct{}
37 ToggleThinkingMsg struct{}
38 OpenReasoningDialogMsg struct{}
39 OpenExternalEditorMsg struct{}
40 ToggleYoloModeMsg struct{}
41 CompactMsg struct {
42 SessionID string
43 }
44)
45
46// Commands represents a dialog that shows available commands.
47type Commands struct {
48 com *common.Common
49 keyMap struct {
50 Select,
51 Next,
52 Previous,
53 Tab,
54 Close key.Binding
55 }
56
57 sessionID string // can be empty for non-session-specific commands
58 selected uicmd.CommandType
59 userCmds []uicmd.Command
60 mcpPrompts *csync.Slice[uicmd.Command]
61
62 help help.Model
63 input textinput.Model
64 list *list.FilterableList
65 width, height int
66}
67
68var _ Dialog = (*Commands)(nil)
69
70// NewCommands creates a new commands dialog.
71func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
72 commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
73 if err != nil {
74 return nil, err
75 }
76
77 mcpPrompts := csync.NewSlice[uicmd.Command]()
78 mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
79
80 c := &Commands{
81 com: com,
82 userCmds: commands,
83 selected: uicmd.SystemCommands,
84 mcpPrompts: mcpPrompts,
85 sessionID: sessionID,
86 }
87
88 help := help.New()
89 help.Styles = com.Styles.DialogHelpStyles()
90
91 c.help = help
92
93 c.list = list.NewFilterableList()
94 c.list.Focus()
95 c.list.SetSelected(0)
96
97 c.input = textinput.New()
98 c.input.SetVirtualCursor(false)
99 c.input.Placeholder = "Type to filter"
100 c.input.SetStyles(com.Styles.TextInput)
101 c.input.Focus()
102
103 c.keyMap.Select = key.NewBinding(
104 key.WithKeys("enter", "ctrl+y"),
105 key.WithHelp("enter", "confirm"),
106 )
107 c.keyMap.Next = key.NewBinding(
108 key.WithKeys("down", "ctrl+n"),
109 key.WithHelp("↓", "next item"),
110 )
111 c.keyMap.Previous = key.NewBinding(
112 key.WithKeys("up", "ctrl+p"),
113 key.WithHelp("↑", "previous item"),
114 )
115 c.keyMap.Tab = key.NewBinding(
116 key.WithKeys("tab"),
117 key.WithHelp("tab", "switch selection"),
118 )
119 closeKey := CloseKey
120 closeKey.SetHelp("esc", "cancel")
121 c.keyMap.Close = closeKey
122
123 // Set initial commands
124 c.setCommandType(c.selected)
125
126 return c, nil
127}
128
129// SetSize sets the size of the dialog.
130func (c *Commands) SetSize(width, height int) {
131 c.width = width
132 c.height = height
133 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
134 c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
135 c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
136 c.help.SetWidth(width)
137}
138
139// ID implements Dialog.
140func (c *Commands) ID() string {
141 return CommandsID
142}
143
144// Update implements Dialog.
145func (c *Commands) Update(msg tea.Msg) tea.Cmd {
146 switch msg := msg.(type) {
147 case tea.KeyPressMsg:
148 switch {
149 case key.Matches(msg, c.keyMap.Previous):
150 c.list.Focus()
151 c.list.SelectPrev()
152 c.list.ScrollToSelected()
153 case key.Matches(msg, c.keyMap.Next):
154 c.list.Focus()
155 c.list.SelectNext()
156 c.list.ScrollToSelected()
157 case key.Matches(msg, c.keyMap.Select):
158 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
159 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
160 return item.Cmd.Handler(item.Cmd) // Huh??
161 }
162 }
163 case key.Matches(msg, c.keyMap.Tab):
164 if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
165 c.selected = c.nextCommandType()
166 c.setCommandType(c.selected)
167 }
168 default:
169 var cmd tea.Cmd
170 c.input, cmd = c.input.Update(msg)
171 // Update the list filter
172 c.list.SetFilter(c.input.Value())
173 return cmd
174 }
175 }
176 return nil
177}
178
179// ReloadMCPPrompts reloads the MCP prompts.
180func (c *Commands) ReloadMCPPrompts() tea.Cmd {
181 c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
182 // If we're currently viewing MCP prompts, refresh the list
183 if c.selected == uicmd.MCPPrompts {
184 c.setCommandType(uicmd.MCPPrompts)
185 }
186 return nil
187}
188
189// Cursor returns the cursor position relative to the dialog.
190func (c *Commands) Cursor() *tea.Cursor {
191 return c.input.Cursor()
192}
193
194// View implements [Dialog].
195func (c *Commands) View() string {
196 t := c.com.Styles
197 selectedFn := func(t uicmd.CommandType) string {
198 if t == c.selected {
199 return "◉ " + t.String()
200 }
201 return "○ " + t.String()
202 }
203
204 parts := []string{
205 selectedFn(uicmd.SystemCommands),
206 }
207 if len(c.userCmds) > 0 {
208 parts = append(parts, selectedFn(uicmd.UserCommands))
209 }
210 if c.mcpPrompts.Len() > 0 {
211 parts = append(parts, selectedFn(uicmd.MCPPrompts))
212 }
213
214 radio := strings.Join(parts, " ")
215 radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
216 if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
217 radio = " " + radio
218 }
219
220 titleStyle := t.Dialog.Title
221 helpStyle := t.Dialog.HelpView
222 dialogStyle := t.Dialog.View.Width(c.width)
223 inputStyle := t.Dialog.InputPrompt
224 helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
225
226 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
227 header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
228 title := titleStyle.Render(header)
229 help := helpStyle.Render(c.help.View(c))
230 listContent := c.list.Render()
231 if nlines := lipgloss.Height(listContent); nlines < c.list.Height() {
232 // pad the list content to avoid jumping when navigating
233 listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines))
234 }
235
236 content := strings.Join([]string{
237 title,
238 "",
239 inputStyle.Render(c.input.View()),
240 "",
241 c.list.Render(),
242 "",
243 help,
244 }, "\n")
245
246 return dialogStyle.Render(content)
247}
248
249// ShortHelp implements [help.KeyMap].
250func (c *Commands) ShortHelp() []key.Binding {
251 upDown := key.NewBinding(
252 key.WithKeys("up", "down"),
253 key.WithHelp("↑/↓", "choose"),
254 )
255 return []key.Binding{
256 c.keyMap.Tab,
257 upDown,
258 c.keyMap.Select,
259 c.keyMap.Close,
260 }
261}
262
263// FullHelp implements [help.KeyMap].
264func (c *Commands) FullHelp() [][]key.Binding {
265 return [][]key.Binding{
266 {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
267 {c.keyMap.Close},
268 }
269}
270
271func (c *Commands) nextCommandType() uicmd.CommandType {
272 switch c.selected {
273 case uicmd.SystemCommands:
274 if len(c.userCmds) > 0 {
275 return uicmd.UserCommands
276 }
277 if c.mcpPrompts.Len() > 0 {
278 return uicmd.MCPPrompts
279 }
280 fallthrough
281 case uicmd.UserCommands:
282 if c.mcpPrompts.Len() > 0 {
283 return uicmd.MCPPrompts
284 }
285 fallthrough
286 case uicmd.MCPPrompts:
287 return uicmd.SystemCommands
288 default:
289 return uicmd.SystemCommands
290 }
291}
292
293func (c *Commands) setCommandType(commandType uicmd.CommandType) {
294 c.selected = commandType
295
296 var commands []uicmd.Command
297 switch c.selected {
298 case uicmd.SystemCommands:
299 commands = c.defaultCommands()
300 case uicmd.UserCommands:
301 commands = c.userCmds
302 case uicmd.MCPPrompts:
303 commands = slices.Collect(c.mcpPrompts.Seq())
304 }
305
306 commandItems := []list.FilterableItem{}
307 for _, cmd := range commands {
308 commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
309 }
310
311 c.list.SetItems(commandItems...)
312 // Reset selection and filter
313 c.list.SetSelected(0)
314 c.input.SetValue("")
315}
316
317// TODO: Rethink this
318func (c *Commands) defaultCommands() []uicmd.Command {
319 commands := []uicmd.Command{
320 {
321 ID: "new_session",
322 Title: "New Session",
323 Description: "start a new session",
324 Shortcut: "ctrl+n",
325 Handler: func(cmd uicmd.Command) tea.Cmd {
326 return util.CmdHandler(NewSessionsMsg{})
327 },
328 },
329 {
330 ID: "switch_session",
331 Title: "Switch Session",
332 Description: "Switch to a different session",
333 Shortcut: "ctrl+s",
334 Handler: func(cmd uicmd.Command) tea.Cmd {
335 return util.CmdHandler(SwitchSessionsMsg{})
336 },
337 },
338 {
339 ID: "switch_model",
340 Title: "Switch Model",
341 Description: "Switch to a different model",
342 Shortcut: "ctrl+l",
343 Handler: func(cmd uicmd.Command) tea.Cmd {
344 return util.CmdHandler(SwitchModelMsg{})
345 },
346 },
347 }
348
349 // Only show compact command if there's an active session
350 if c.sessionID != "" {
351 commands = append(commands, uicmd.Command{
352 ID: "Summarize",
353 Title: "Summarize Session",
354 Description: "Summarize the current session and create a new one with the summary",
355 Handler: func(cmd uicmd.Command) tea.Cmd {
356 return util.CmdHandler(CompactMsg{
357 SessionID: c.sessionID,
358 })
359 },
360 })
361 }
362
363 // Add reasoning toggle for models that support it
364 cfg := c.com.Config()
365 if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
366 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
367 model := cfg.GetModelByType(agentCfg.Model)
368 if providerCfg != nil && model != nil && model.CanReason {
369 selectedModel := cfg.Models[agentCfg.Model]
370
371 // Anthropic models: thinking toggle
372 if providerCfg.Type == catwalk.TypeAnthropic {
373 status := "Enable"
374 if selectedModel.Think {
375 status = "Disable"
376 }
377 commands = append(commands, uicmd.Command{
378 ID: "toggle_thinking",
379 Title: status + " Thinking Mode",
380 Description: "Toggle model thinking for reasoning-capable models",
381 Handler: func(cmd uicmd.Command) tea.Cmd {
382 return util.CmdHandler(ToggleThinkingMsg{})
383 },
384 })
385 }
386
387 // OpenAI models: reasoning effort dialog
388 if len(model.ReasoningLevels) > 0 {
389 commands = append(commands, uicmd.Command{
390 ID: "select_reasoning_effort",
391 Title: "Select Reasoning Effort",
392 Description: "Choose reasoning effort level (low/medium/high)",
393 Handler: func(cmd uicmd.Command) tea.Cmd {
394 return util.CmdHandler(OpenReasoningDialogMsg{})
395 },
396 })
397 }
398 }
399 }
400 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
401 // TODO: Get. Rid. Of. Magic. Numbers!
402 if c.width > 120 && c.sessionID != "" {
403 commands = append(commands, uicmd.Command{
404 ID: "toggle_sidebar",
405 Title: "Toggle Sidebar",
406 Description: "Toggle between compact and normal layout",
407 Handler: func(cmd uicmd.Command) tea.Cmd {
408 return util.CmdHandler(ToggleCompactModeMsg{})
409 },
410 })
411 }
412 if c.sessionID != "" {
413 cfg := c.com.Config()
414 agentCfg := cfg.Agents[config.AgentCoder]
415 model := cfg.GetModelByType(agentCfg.Model)
416 if model.SupportsImages {
417 commands = append(commands, uicmd.Command{
418 ID: "file_picker",
419 Title: "Open File Picker",
420 Shortcut: "ctrl+f",
421 Description: "Open file picker",
422 Handler: func(cmd uicmd.Command) tea.Cmd {
423 return util.CmdHandler(OpenFilePickerMsg{})
424 },
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, uicmd.Command{
433 ID: "open_external_editor",
434 Title: "Open External Editor",
435 Shortcut: "ctrl+o",
436 Description: "Open external editor to compose message",
437 Handler: func(cmd uicmd.Command) tea.Cmd {
438 return util.CmdHandler(OpenExternalEditorMsg{})
439 },
440 })
441 }
442
443 return append(commands, []uicmd.Command{
444 {
445 ID: "toggle_yolo",
446 Title: "Toggle Yolo Mode",
447 Description: "Toggle yolo mode",
448 Handler: func(cmd uicmd.Command) tea.Cmd {
449 return util.CmdHandler(ToggleYoloModeMsg{})
450 },
451 },
452 {
453 ID: "toggle_help",
454 Title: "Toggle Help",
455 Shortcut: "ctrl+g",
456 Description: "Toggle help",
457 Handler: func(cmd uicmd.Command) tea.Cmd {
458 return util.CmdHandler(ToggleHelpMsg{})
459 },
460 },
461 {
462 ID: "init",
463 Title: "Initialize Project",
464 Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
465 Handler: func(cmd uicmd.Command) tea.Cmd {
466 initPrompt, err := agent.InitializePrompt(*c.com.Config())
467 if err != nil {
468 return util.ReportError(err)
469 }
470 return util.CmdHandler(chat.SendMsg{
471 Text: initPrompt,
472 })
473 },
474 },
475 {
476 ID: "quit",
477 Title: "Quit",
478 Description: "Quit",
479 Shortcut: "ctrl+c",
480 Handler: func(cmd uicmd.Command) tea.Cmd {
481 return util.CmdHandler(QuitMsg{})
482 },
483 },
484 }...)
485}