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