commands.go 13 KB

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