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 if cmd != nil {
180 return cmd()
181 }
182 }
183 }
184 return nil
185}
186
187// ReloadMCPPrompts reloads the MCP prompts.
188func (c *Commands) ReloadMCPPrompts() tea.Cmd {
189 c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
190 // If we're currently viewing MCP prompts, refresh the list
191 if c.selected == uicmd.MCPPrompts {
192 c.setCommandType(uicmd.MCPPrompts)
193 }
194 return nil
195}
196
197// Cursor returns the cursor position relative to the dialog.
198func (c *Commands) Cursor() *tea.Cursor {
199 return InputCursor(c.com.Styles, c.input.Cursor())
200}
201
202// commandsRadioView generates the command type selector radio buttons.
203func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
204 if !hasUserCmds && !hasMCPPrompts {
205 return ""
206 }
207
208 selectedFn := func(t uicmd.CommandType) string {
209 if t == selected {
210 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
211 }
212 return sty.RadioOff.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
213 }
214
215 parts := []string{
216 selectedFn(uicmd.SystemCommands),
217 }
218
219 if hasUserCmds {
220 parts = append(parts, selectedFn(uicmd.UserCommands))
221 }
222 if hasMCPPrompts {
223 parts = append(parts, selectedFn(uicmd.MCPPrompts))
224 }
225
226 return strings.Join(parts, " ")
227}
228
229// View implements [Dialog].
230func (c *Commands) View() string {
231 t := c.com.Styles
232 radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
233 titleStyle := t.Dialog.Title
234 dialogStyle := t.Dialog.View.Width(c.width)
235 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
236 header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
237 return HeaderInputListHelpView(t, c.width, c.list.Height(), header,
238 c.input.View(), c.list.Render(), c.help.View(c))
239}
240
241// ShortHelp implements [help.KeyMap].
242func (c *Commands) ShortHelp() []key.Binding {
243 upDown := key.NewBinding(
244 key.WithKeys("up", "down"),
245 key.WithHelp("↑/↓", "choose"),
246 )
247 return []key.Binding{
248 c.keyMap.Tab,
249 upDown,
250 c.keyMap.Select,
251 c.keyMap.Close,
252 }
253}
254
255// FullHelp implements [help.KeyMap].
256func (c *Commands) FullHelp() [][]key.Binding {
257 return [][]key.Binding{
258 {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
259 {c.keyMap.Close},
260 }
261}
262
263func (c *Commands) nextCommandType() uicmd.CommandType {
264 switch c.selected {
265 case uicmd.SystemCommands:
266 if len(c.userCmds) > 0 {
267 return uicmd.UserCommands
268 }
269 if c.mcpPrompts.Len() > 0 {
270 return uicmd.MCPPrompts
271 }
272 fallthrough
273 case uicmd.UserCommands:
274 if c.mcpPrompts.Len() > 0 {
275 return uicmd.MCPPrompts
276 }
277 fallthrough
278 case uicmd.MCPPrompts:
279 return uicmd.SystemCommands
280 default:
281 return uicmd.SystemCommands
282 }
283}
284
285func (c *Commands) setCommandType(commandType uicmd.CommandType) {
286 c.selected = commandType
287
288 var commands []uicmd.Command
289 switch c.selected {
290 case uicmd.SystemCommands:
291 commands = c.defaultCommands()
292 case uicmd.UserCommands:
293 commands = c.userCmds
294 case uicmd.MCPPrompts:
295 commands = slices.Collect(c.mcpPrompts.Seq())
296 }
297
298 commandItems := []list.FilterableItem{}
299 for _, cmd := range commands {
300 commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
301 }
302
303 c.list.SetItems(commandItems...)
304 c.list.SetSelected(0)
305 c.list.SetFilter("")
306 c.list.ScrollToTop()
307 c.list.SetSelected(0)
308 c.input.SetValue("")
309}
310
311// TODO: Rethink this
312func (c *Commands) defaultCommands() []uicmd.Command {
313 commands := []uicmd.Command{
314 {
315 ID: "new_session",
316 Title: "New Session",
317 Description: "start a new session",
318 Shortcut: "ctrl+n",
319 Handler: func(cmd uicmd.Command) tea.Cmd {
320 return uiutil.CmdHandler(NewSessionsMsg{})
321 },
322 },
323 {
324 ID: "switch_session",
325 Title: "Switch Session",
326 Description: "Switch to a different session",
327 Shortcut: "ctrl+s",
328 Handler: func(cmd uicmd.Command) tea.Cmd {
329 return uiutil.CmdHandler(OpenDialogMsg{SessionsID})
330 },
331 },
332 {
333 ID: "switch_model",
334 Title: "Switch Model",
335 Description: "Switch to a different model",
336 // FIXME: The shortcut might get updated if enhanced keyboard is supported.
337 Shortcut: "ctrl+l",
338 Handler: func(cmd uicmd.Command) tea.Cmd {
339 return uiutil.CmdHandler(OpenDialogMsg{ModelsID})
340 },
341 },
342 }
343
344 // Only show compact command if there's an active session
345 if c.sessionID != "" {
346 commands = append(commands, uicmd.Command{
347 ID: "Summarize",
348 Title: "Summarize Session",
349 Description: "Summarize the current session and create a new one with the summary",
350 Handler: func(cmd uicmd.Command) tea.Cmd {
351 return uiutil.CmdHandler(CompactMsg{
352 SessionID: c.sessionID,
353 })
354 },
355 })
356 }
357
358 // Add reasoning toggle for models that support it
359 cfg := c.com.Config()
360 if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
361 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
362 model := cfg.GetModelByType(agentCfg.Model)
363 if providerCfg != nil && model != nil && model.CanReason {
364 selectedModel := cfg.Models[agentCfg.Model]
365
366 // Anthropic models: thinking toggle
367 if providerCfg.Type == catwalk.TypeAnthropic {
368 status := "Enable"
369 if selectedModel.Think {
370 status = "Disable"
371 }
372 commands = append(commands, uicmd.Command{
373 ID: "toggle_thinking",
374 Title: status + " Thinking Mode",
375 Description: "Toggle model thinking for reasoning-capable models",
376 Handler: func(cmd uicmd.Command) tea.Cmd {
377 return uiutil.CmdHandler(ToggleThinkingMsg{})
378 },
379 })
380 }
381
382 // OpenAI models: reasoning effort dialog
383 if len(model.ReasoningLevels) > 0 {
384 commands = append(commands, uicmd.Command{
385 ID: "select_reasoning_effort",
386 Title: "Select Reasoning Effort",
387 Description: "Choose reasoning effort level (low/medium/high)",
388 Handler: func(cmd uicmd.Command) tea.Cmd {
389 return uiutil.CmdHandler(OpenReasoningDialogMsg{})
390 },
391 })
392 }
393 }
394 }
395 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
396 // TODO: Get. Rid. Of. Magic. Numbers!
397 if c.width > 120 && c.sessionID != "" {
398 commands = append(commands, uicmd.Command{
399 ID: "toggle_sidebar",
400 Title: "Toggle Sidebar",
401 Description: "Toggle between compact and normal layout",
402 Handler: func(cmd uicmd.Command) tea.Cmd {
403 return uiutil.CmdHandler(ToggleCompactModeMsg{})
404 },
405 })
406 }
407 if c.sessionID != "" {
408 cfg := c.com.Config()
409 agentCfg := cfg.Agents[config.AgentCoder]
410 model := cfg.GetModelByType(agentCfg.Model)
411 if model.SupportsImages {
412 commands = append(commands, uicmd.Command{
413 ID: "file_picker",
414 Title: "Open File Picker",
415 Shortcut: "ctrl+f",
416 Description: "Open file picker",
417 Handler: func(cmd uicmd.Command) tea.Cmd {
418 return uiutil.CmdHandler(OpenFilePickerMsg{})
419 },
420 })
421 }
422 }
423
424 // Add external editor command if $EDITOR is available
425 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
426 if os.Getenv("EDITOR") != "" {
427 commands = append(commands, uicmd.Command{
428 ID: "open_external_editor",
429 Title: "Open External Editor",
430 Shortcut: "ctrl+o",
431 Description: "Open external editor to compose message",
432 Handler: func(cmd uicmd.Command) tea.Cmd {
433 return uiutil.CmdHandler(OpenExternalEditorMsg{})
434 },
435 })
436 }
437
438 return append(commands, []uicmd.Command{
439 {
440 ID: "toggle_yolo",
441 Title: "Toggle Yolo Mode",
442 Description: "Toggle yolo mode",
443 Handler: func(cmd uicmd.Command) tea.Cmd {
444 return uiutil.CmdHandler(ToggleYoloModeMsg{})
445 },
446 },
447 {
448 ID: "toggle_help",
449 Title: "Toggle Help",
450 Shortcut: "ctrl+g",
451 Description: "Toggle help",
452 Handler: func(cmd uicmd.Command) tea.Cmd {
453 return uiutil.CmdHandler(ToggleHelpMsg{})
454 },
455 },
456 {
457 ID: "init",
458 Title: "Initialize Project",
459 Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
460 Handler: func(cmd uicmd.Command) tea.Cmd {
461 initPrompt, err := agent.InitializePrompt(*c.com.Config())
462 if err != nil {
463 return uiutil.ReportError(err)
464 }
465 return uiutil.CmdHandler(chat.SendMsg{
466 Text: initPrompt,
467 })
468 },
469 },
470 {
471 ID: "quit",
472 Title: "Quit",
473 Description: "Quit",
474 Shortcut: "ctrl+c",
475 Handler: func(cmd uicmd.Command) tea.Cmd {
476 return uiutil.CmdHandler(tea.QuitMsg{})
477 },
478 },
479 }...)
480}