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