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