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(100, area.Dx()))
224 height := max(0, min(30, area.Dy()))
225 c.width = width
226 // TODO: Why do we need this 2?
227 innerWidth := width - t.Dialog.View.GetHorizontalFrameSize() - 2
228 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + 1 + // (1) title content
229 t.Dialog.InputPrompt.GetVerticalFrameSize() + 1 + // (1) input content
230 t.Dialog.HelpView.GetVerticalFrameSize() +
231 // TODO: Why do we need this 2?
232 t.Dialog.View.GetVerticalFrameSize() + 2
233 c.input.SetWidth(innerWidth - t.Dialog.InputPrompt.GetHorizontalFrameSize() - 1) // (1) cursor padding
234 c.list.SetSize(innerWidth, height-heightOffset)
235 c.help.SetWidth(innerWidth)
236
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(width)
240 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
241 helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
242 header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
243 view := HeaderInputListHelpView(t, width, c.list.Height(), header,
244 c.input.View(), c.list.Render(), helpView)
245
246 cur := c.Cursor()
247 DrawCenterCursor(scr, area, view, cur)
248 return cur
249}
250
251// ShortHelp implements [help.KeyMap].
252func (c *Commands) ShortHelp() []key.Binding {
253 return []key.Binding{
254 c.keyMap.Tab,
255 c.keyMap.UpDown,
256 c.keyMap.Select,
257 c.keyMap.Close,
258 }
259}
260
261// FullHelp implements [help.KeyMap].
262func (c *Commands) FullHelp() [][]key.Binding {
263 return [][]key.Binding{
264 {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
265 {c.keyMap.Close},
266 }
267}
268
269func (c *Commands) nextCommandType() uicmd.CommandType {
270 switch c.selected {
271 case uicmd.SystemCommands:
272 if len(c.userCmds) > 0 {
273 return uicmd.UserCommands
274 }
275 if c.mcpPrompts.Len() > 0 {
276 return uicmd.MCPPrompts
277 }
278 fallthrough
279 case uicmd.UserCommands:
280 if c.mcpPrompts.Len() > 0 {
281 return uicmd.MCPPrompts
282 }
283 fallthrough
284 case uicmd.MCPPrompts:
285 return uicmd.SystemCommands
286 default:
287 return uicmd.SystemCommands
288 }
289}
290
291func (c *Commands) setCommandType(commandType uicmd.CommandType) {
292 c.selected = commandType
293
294 var commands []uicmd.Command
295 switch c.selected {
296 case uicmd.SystemCommands:
297 commands = c.defaultCommands()
298 case uicmd.UserCommands:
299 commands = c.userCmds
300 case uicmd.MCPPrompts:
301 commands = slices.Collect(c.mcpPrompts.Seq())
302 }
303
304 commandItems := []list.FilterableItem{}
305 for _, cmd := range commands {
306 commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
307 }
308
309 c.list.SetItems(commandItems...)
310 c.list.SetSelected(0)
311 c.list.SetFilter("")
312 c.list.ScrollToTop()
313 c.list.SetSelected(0)
314 c.input.SetValue("")
315}
316
317// TODO: Rethink this
318func (c *Commands) defaultCommands() []uicmd.Command {
319 commands := []uicmd.Command{
320 {
321 ID: "new_session",
322 Title: "New Session",
323 Description: "start a new session",
324 Shortcut: "ctrl+n",
325 Handler: func(cmd uicmd.Command) tea.Cmd {
326 return uiutil.CmdHandler(ActionNewSession{})
327 },
328 },
329 {
330 ID: "switch_session",
331 Title: "Switch Session",
332 Description: "Switch to a different session",
333 Shortcut: "ctrl+s",
334 Handler: func(cmd uicmd.Command) tea.Cmd {
335 return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
336 },
337 },
338 {
339 ID: "switch_model",
340 Title: "Switch Model",
341 Description: "Switch to a different model",
342 // FIXME: The shortcut might get updated if enhanced keyboard is supported.
343 Shortcut: "ctrl+l",
344 Handler: func(cmd uicmd.Command) tea.Cmd {
345 return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
346 },
347 },
348 }
349
350 // Only show compact command if there's an active session
351 if c.sessionID != "" {
352 commands = append(commands, uicmd.Command{
353 ID: "Summarize",
354 Title: "Summarize Session",
355 Description: "Summarize the current session and create a new one with the summary",
356 Handler: func(cmd uicmd.Command) tea.Cmd {
357 return uiutil.CmdHandler(ActionSummarize{
358 SessionID: c.sessionID,
359 })
360 },
361 })
362 }
363
364 // Add reasoning toggle for models that support it
365 cfg := c.com.Config()
366 if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
367 providerCfg := cfg.GetProviderForModel(agentCfg.Model)
368 model := cfg.GetModelByType(agentCfg.Model)
369 if providerCfg != nil && model != nil && model.CanReason {
370 selectedModel := cfg.Models[agentCfg.Model]
371
372 // Anthropic models: thinking toggle
373 if providerCfg.Type == catwalk.TypeAnthropic {
374 status := "Enable"
375 if selectedModel.Think {
376 status = "Disable"
377 }
378 commands = append(commands, uicmd.Command{
379 ID: "toggle_thinking",
380 Title: status + " Thinking Mode",
381 Description: "Toggle model thinking for reasoning-capable models",
382 Handler: func(cmd uicmd.Command) tea.Cmd {
383 return uiutil.CmdHandler(ActionToggleThinking{})
384 },
385 })
386 }
387
388 // OpenAI models: reasoning effort dialog
389 if len(model.ReasoningLevels) > 0 {
390 commands = append(commands, uicmd.Command{
391 ID: "select_reasoning_effort",
392 Title: "Select Reasoning Effort",
393 Description: "Choose reasoning effort level (low/medium/high)",
394 Handler: func(cmd uicmd.Command) tea.Cmd {
395 return uiutil.CmdHandler(ActionOpenDialog{
396 // TODO: Pass reasoning dialog id
397 })
398 },
399 })
400 }
401 }
402 }
403 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
404 // TODO: Get. Rid. Of. Magic. Numbers!
405 if c.width > 120 && c.sessionID != "" {
406 commands = append(commands, uicmd.Command{
407 ID: "toggle_sidebar",
408 Title: "Toggle Sidebar",
409 Description: "Toggle between compact and normal layout",
410 Handler: func(cmd uicmd.Command) tea.Cmd {
411 return uiutil.CmdHandler(ActionToggleCompactMode{})
412 },
413 })
414 }
415 if c.sessionID != "" {
416 cfg := c.com.Config()
417 agentCfg := cfg.Agents[config.AgentCoder]
418 model := cfg.GetModelByType(agentCfg.Model)
419 if model.SupportsImages {
420 commands = append(commands, uicmd.Command{
421 ID: "file_picker",
422 Title: "Open File Picker",
423 Shortcut: "ctrl+f",
424 Description: "Open file picker",
425 Handler: func(cmd uicmd.Command) tea.Cmd {
426 return uiutil.CmdHandler(ActionOpenDialog{
427 // TODO: Pass file picker dialog id
428 })
429 },
430 })
431 }
432 }
433
434 // Add external editor command if $EDITOR is available
435 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
436 if os.Getenv("EDITOR") != "" {
437 commands = append(commands, uicmd.Command{
438 ID: "open_external_editor",
439 Title: "Open External Editor",
440 Shortcut: "ctrl+o",
441 Description: "Open external editor to compose message",
442 Handler: func(cmd uicmd.Command) tea.Cmd {
443 return uiutil.CmdHandler(ActionExternalEditor{})
444 },
445 })
446 }
447
448 return append(commands, []uicmd.Command{
449 {
450 ID: "toggle_yolo",
451 Title: "Toggle Yolo Mode",
452 Description: "Toggle yolo mode",
453 Handler: func(cmd uicmd.Command) tea.Cmd {
454 return uiutil.CmdHandler(ActionToggleYoloMode{})
455 },
456 },
457 {
458 ID: "toggle_help",
459 Title: "Toggle Help",
460 Shortcut: "ctrl+g",
461 Description: "Toggle help",
462 Handler: func(cmd uicmd.Command) tea.Cmd {
463 return uiutil.CmdHandler(ActionToggleHelp{})
464 },
465 },
466 {
467 ID: "init",
468 Title: "Initialize Project",
469 Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
470 Handler: func(cmd uicmd.Command) tea.Cmd {
471 initPrompt, err := agent.InitializePrompt(*c.com.Config())
472 if err != nil {
473 return uiutil.ReportError(err)
474 }
475 return uiutil.CmdHandler(chat.SendMsg{
476 Text: initPrompt,
477 })
478 },
479 },
480 {
481 ID: "quit",
482 Title: "Quit",
483 Description: "Quit",
484 Shortcut: "ctrl+c",
485 Handler: func(cmd uicmd.Command) tea.Cmd {
486 return uiutil.CmdHandler(tea.QuitMsg{})
487 },
488 },
489 }...)
490}