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