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