1package commands
2
3import (
4 "fmt"
5 "os"
6 "slices"
7 "strings"
8
9 "charm.land/bubbles/v2/help"
10 "charm.land/bubbles/v2/key"
11 tea "charm.land/bubbletea/v2"
12 "charm.land/lipgloss/v2"
13 "github.com/charmbracelet/catwalk/pkg/catwalk"
14
15 "github.com/charmbracelet/crush/internal/agent"
16 "github.com/charmbracelet/crush/internal/agent/hyper"
17 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
18 "github.com/charmbracelet/crush/internal/config"
19 "github.com/charmbracelet/crush/internal/csync"
20 "github.com/charmbracelet/crush/internal/pubsub"
21 "github.com/charmbracelet/crush/internal/tui/components/chat"
22 "github.com/charmbracelet/crush/internal/tui/components/core"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
24 "github.com/charmbracelet/crush/internal/tui/exp/list"
25 "github.com/charmbracelet/crush/internal/tui/styles"
26 "github.com/charmbracelet/crush/internal/tui/util"
27 "github.com/charmbracelet/crush/internal/uicmd"
28)
29
30const (
31 CommandsDialogID dialogs.DialogID = "commands"
32
33 defaultWidth int = 70
34)
35
36type commandType = uicmd.CommandType
37
38const (
39 SystemCommands = uicmd.SystemCommands
40 UserCommands = uicmd.UserCommands
41 MCPPrompts = uicmd.MCPPrompts
42)
43
44type listModel = list.FilterableList[list.CompletionItem[Command]]
45
46// Command represents a command that can be executed
47type (
48 Command = uicmd.Command
49 CommandRunCustomMsg = uicmd.CommandRunCustomMsg
50 ShowMCPPromptArgumentsDialogMsg = uicmd.ShowMCPPromptArgumentsDialogMsg
51)
52
53// CommandsDialog represents the commands dialog.
54type CommandsDialog interface {
55 dialogs.DialogModel
56}
57
58type commandDialogCmp struct {
59 width int
60 wWidth int // Width of the terminal window
61 wHeight int // Height of the terminal window
62
63 commandList listModel
64 keyMap CommandsDialogKeyMap
65 help help.Model
66 selected commandType // Selected SystemCommands, UserCommands, or MCPPrompts
67 userCommands []Command // User-defined commands
68 mcpPrompts *csync.Slice[Command] // MCP prompts
69 sessionID string // Current session ID
70}
71
72type (
73 SwitchSessionsMsg struct{}
74 NewSessionsMsg struct{}
75 SwitchModelMsg struct{}
76 QuitMsg struct{}
77 OpenFilePickerMsg struct{}
78 ToggleHelpMsg struct{}
79 ToggleCompactModeMsg struct{}
80 ToggleThinkingMsg struct{}
81 OpenReasoningDialogMsg struct{}
82 OpenExternalEditorMsg struct{}
83 ToggleYoloModeMsg struct{}
84 CompactMsg struct {
85 SessionID string
86 }
87)
88
89func NewCommandDialog(sessionID string) CommandsDialog {
90 keyMap := DefaultCommandsDialogKeyMap()
91 listKeyMap := list.DefaultKeyMap()
92 listKeyMap.Down.SetEnabled(false)
93 listKeyMap.Up.SetEnabled(false)
94 listKeyMap.DownOneItem = keyMap.Next
95 listKeyMap.UpOneItem = keyMap.Previous
96
97 t := styles.CurrentTheme()
98 inputStyle := t.S().Base.PaddingLeft(1).PaddingBottom(1)
99 commandList := list.NewFilterableList(
100 []list.CompletionItem[Command]{},
101 list.WithFilterInputStyle(inputStyle),
102 list.WithFilterListOptions(
103 list.WithKeyMap(listKeyMap),
104 list.WithWrapNavigation(),
105 list.WithResizeByList(),
106 ),
107 )
108 help := help.New()
109 help.Styles = t.S().Help
110 return &commandDialogCmp{
111 commandList: commandList,
112 width: defaultWidth,
113 keyMap: DefaultCommandsDialogKeyMap(),
114 help: help,
115 selected: SystemCommands,
116 sessionID: sessionID,
117 mcpPrompts: csync.NewSlice[Command](),
118 }
119}
120
121func (c *commandDialogCmp) Init() tea.Cmd {
122 commands, err := uicmd.LoadCustomCommands()
123 if err != nil {
124 return util.ReportError(err)
125 }
126 c.userCommands = commands
127 c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
128 return c.setCommandType(c.selected)
129}
130
131func (c *commandDialogCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
132 switch msg := msg.(type) {
133 case tea.WindowSizeMsg:
134 c.wWidth = msg.Width
135 c.wHeight = msg.Height
136 return c, tea.Batch(
137 c.setCommandType(c.selected),
138 c.commandList.SetSize(c.listWidth(), c.listHeight()),
139 )
140 case pubsub.Event[mcp.Event]:
141 // Reload MCP prompts when MCP state changes
142 if msg.Type == pubsub.UpdatedEvent {
143 c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
144 // If we're currently viewing MCP prompts, refresh the list
145 if c.selected == MCPPrompts {
146 return c, c.setCommandType(MCPPrompts)
147 }
148 return c, nil
149 }
150 case tea.KeyPressMsg:
151 switch {
152 case key.Matches(msg, c.keyMap.Select):
153 selectedItem := c.commandList.SelectedItem()
154 if selectedItem == nil {
155 return c, nil // No item selected, do nothing
156 }
157 command := (*selectedItem).Value()
158 return c, tea.Sequence(
159 util.CmdHandler(dialogs.CloseDialogMsg{}),
160 command.Handler(command),
161 )
162 case key.Matches(msg, c.keyMap.Tab):
163 if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
164 return c, nil
165 }
166 return c, c.setCommandType(c.next())
167 case key.Matches(msg, c.keyMap.Close):
168 return c, util.CmdHandler(dialogs.CloseDialogMsg{})
169 default:
170 u, cmd := c.commandList.Update(msg)
171 c.commandList = u.(listModel)
172 return c, cmd
173 }
174 }
175 return c, nil
176}
177
178func (c *commandDialogCmp) next() commandType {
179 switch c.selected {
180 case SystemCommands:
181 if len(c.userCommands) > 0 {
182 return UserCommands
183 }
184 if c.mcpPrompts.Len() > 0 {
185 return MCPPrompts
186 }
187 fallthrough
188 case UserCommands:
189 if c.mcpPrompts.Len() > 0 {
190 return MCPPrompts
191 }
192 fallthrough
193 case MCPPrompts:
194 return SystemCommands
195 default:
196 return SystemCommands
197 }
198}
199
200func (c *commandDialogCmp) View() string {
201 t := styles.CurrentTheme()
202 listView := c.commandList
203 radio := c.commandTypeRadio()
204
205 header := t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5) + " " + radio)
206 if len(c.userCommands) == 0 && c.mcpPrompts.Len() == 0 {
207 header = t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-4))
208 }
209 content := lipgloss.JoinVertical(
210 lipgloss.Left,
211 header,
212 listView.View(),
213 "",
214 t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
215 )
216 return c.style().Render(content)
217}
218
219func (c *commandDialogCmp) Cursor() *tea.Cursor {
220 if cursor, ok := c.commandList.(util.Cursor); ok {
221 cursor := cursor.Cursor()
222 if cursor != nil {
223 cursor = c.moveCursor(cursor)
224 }
225 return cursor
226 }
227 return nil
228}
229
230func (c *commandDialogCmp) commandTypeRadio() string {
231 t := styles.CurrentTheme()
232
233 fn := func(i commandType) string {
234 if i == c.selected {
235 return "◉ " + i.String()
236 }
237 return "○ " + i.String()
238 }
239
240 parts := []string{
241 fn(SystemCommands),
242 }
243 if len(c.userCommands) > 0 {
244 parts = append(parts, fn(UserCommands))
245 }
246 if c.mcpPrompts.Len() > 0 {
247 parts = append(parts, fn(MCPPrompts))
248 }
249 return t.S().Base.Foreground(t.FgHalfMuted).Render(strings.Join(parts, " "))
250}
251
252func (c *commandDialogCmp) listWidth() int {
253 return defaultWidth - 2 // 4 for padding
254}
255
256func (c *commandDialogCmp) setCommandType(commandType commandType) tea.Cmd {
257 c.selected = commandType
258
259 var commands []Command
260 switch c.selected {
261 case SystemCommands:
262 commands = c.defaultCommands()
263 case UserCommands:
264 commands = c.userCommands
265 case MCPPrompts:
266 commands = slices.Collect(c.mcpPrompts.Seq())
267 }
268
269 commandItems := []list.CompletionItem[Command]{}
270 for _, cmd := range commands {
271 opts := []list.CompletionItemOption{
272 list.WithCompletionID(cmd.ID),
273 }
274 if cmd.Shortcut != "" {
275 opts = append(
276 opts,
277 list.WithCompletionShortcut(cmd.Shortcut),
278 )
279 }
280 commandItems = append(commandItems, list.NewCompletionItem(cmd.Title, cmd, opts...))
281 }
282 return c.commandList.SetItems(commandItems)
283}
284
285func (c *commandDialogCmp) listHeight() int {
286 listHeigh := len(c.commandList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
287 return min(listHeigh, c.wHeight/2)
288}
289
290func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
291 row, col := c.Position()
292 offset := row + 3
293 cursor.Y += offset
294 cursor.X = cursor.X + col + 2
295 return cursor
296}
297
298func (c *commandDialogCmp) style() lipgloss.Style {
299 t := styles.CurrentTheme()
300 return t.S().Base.
301 Width(c.width).
302 Border(lipgloss.RoundedBorder()).
303 BorderForeground(t.BorderFocus)
304}
305
306func (c *commandDialogCmp) Position() (int, int) {
307 row := c.wHeight/4 - 2 // just a bit above the center
308 col := c.wWidth / 2
309 col -= c.width / 2
310 return row, col
311}
312
313func (c *commandDialogCmp) defaultCommands() []Command {
314 commands := []Command{
315 {
316 ID: "new_session",
317 Title: "New Session",
318 Description: "start a new session",
319 Shortcut: "ctrl+n",
320 Handler: func(cmd Command) tea.Cmd {
321 return util.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 Command) tea.Cmd {
330 return util.CmdHandler(SwitchSessionsMsg{})
331 },
332 },
333 {
334 ID: "switch_model",
335 Title: "Switch Model",
336 Description: "Switch to a different model",
337 Shortcut: "ctrl+l",
338 Handler: func(cmd Command) tea.Cmd {
339 return util.CmdHandler(SwitchModelMsg{})
340 },
341 },
342 }
343
344 // Only show compact command if there's an active session
345 if c.sessionID != "" {
346 commands = append(commands, 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 Command) tea.Cmd {
351 return util.CmdHandler(CompactMsg{
352 SessionID: c.sessionID,
353 })
354 },
355 })
356 }
357
358 // Add reasoning toggle for models that support it
359 cfg := config.Get()
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 || providerCfg.Type == catwalk.Type(hyper.Name) {
368 status := "Enable"
369 if selectedModel.Think {
370 status = "Disable"
371 }
372 commands = append(commands, Command{
373 ID: "toggle_thinking",
374 Title: status + " Thinking Mode",
375 Description: "Toggle model thinking for reasoning-capable models",
376 Handler: func(cmd Command) tea.Cmd {
377 return util.CmdHandler(ToggleThinkingMsg{})
378 },
379 })
380 }
381
382 // OpenAI models: reasoning effort dialog
383 if len(model.ReasoningLevels) > 0 {
384 commands = append(commands, Command{
385 ID: "select_reasoning_effort",
386 Title: "Select Reasoning Effort",
387 Description: "Choose reasoning effort level (low/medium/high)",
388 Handler: func(cmd Command) tea.Cmd {
389 return util.CmdHandler(OpenReasoningDialogMsg{})
390 },
391 })
392 }
393 }
394 }
395 // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
396 if c.wWidth > 120 && c.sessionID != "" {
397 commands = append(commands, Command{
398 ID: "toggle_sidebar",
399 Title: "Toggle Sidebar",
400 Description: "Toggle between compact and normal layout",
401 Handler: func(cmd Command) tea.Cmd {
402 return util.CmdHandler(ToggleCompactModeMsg{})
403 },
404 })
405 }
406 if c.sessionID != "" {
407 agentCfg := config.Get().Agents[config.AgentCoder]
408 model := config.Get().GetModelByType(agentCfg.Model)
409 if model.SupportsImages {
410 commands = append(commands, Command{
411 ID: "file_picker",
412 Title: "Open File Picker",
413 Shortcut: "ctrl+f",
414 Description: "Open file picker",
415 Handler: func(cmd Command) tea.Cmd {
416 return util.CmdHandler(OpenFilePickerMsg{})
417 },
418 })
419 }
420 }
421
422 // Add external editor command if $EDITOR is available
423 if os.Getenv("EDITOR") != "" {
424 commands = append(commands, 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 Command) tea.Cmd {
430 return util.CmdHandler(OpenExternalEditorMsg{})
431 },
432 })
433 }
434
435 return append(commands, []Command{
436 {
437 ID: "toggle_yolo",
438 Title: "Toggle Yolo Mode",
439 Description: "Toggle yolo mode",
440 Handler: func(cmd Command) tea.Cmd {
441 return util.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 Command) tea.Cmd {
450 return util.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 Command) tea.Cmd {
458 initPrompt, err := agent.InitializePrompt(*config.Get())
459 if err != nil {
460 return util.ReportError(err)
461 }
462 return util.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 Command) tea.Cmd {
473 return util.CmdHandler(QuitMsg{})
474 },
475 },
476 }...)
477}
478
479func (c *commandDialogCmp) ID() dialogs.DialogID {
480 return CommandsDialogID
481}