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