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