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