@@ -0,0 +1,485 @@
9+ "charm.land/bubbles/v2/help"
10+ "charm.land/bubbles/v2/key"
11+ "charm.land/bubbles/v2/textinput"
12+ tea "charm.land/bubbletea/v2"
13+ "charm.land/lipgloss/v2"
14+ "github.com/charmbracelet/catwalk/pkg/catwalk"
15+ "github.com/charmbracelet/crush/internal/agent"
16+ "github.com/charmbracelet/crush/internal/config"
17+ "github.com/charmbracelet/crush/internal/csync"
18+ "github.com/charmbracelet/crush/internal/tui/components/chat"
19+ "github.com/charmbracelet/crush/internal/tui/util"
20+ "github.com/charmbracelet/crush/internal/ui/common"
21+ "github.com/charmbracelet/crush/internal/ui/list"
22+ "github.com/charmbracelet/crush/internal/uicmd"
25+// CommandsID is the identifier for the commands dialog.
26+const CommandsID = "commands"
28+// Messages for commands
30+ SwitchSessionsMsg struct{}
31+ NewSessionsMsg struct{}
32+ SwitchModelMsg struct{}
34+ OpenFilePickerMsg struct{}
35+ ToggleHelpMsg struct{}
36+ ToggleCompactModeMsg struct{}
37+ ToggleThinkingMsg struct{}
38+ OpenReasoningDialogMsg struct{}
39+ OpenExternalEditorMsg struct{}
40+ ToggleYoloModeMsg struct{}
46+// Commands represents a dialog that shows available commands.
47+type Commands struct {
57+ sessionID string // can be empty for non-session-specific commands
58+ selected uicmd.CommandType
59+ userCmds []uicmd.Command
60+ mcpPrompts *csync.Slice[uicmd.Command]
63+ input textinput.Model
64+ list *list.FilterableList
68+var _ Dialog = (*Commands)(nil)
70+// NewCommands creates a new commands dialog.
71+func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
72+ commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
77+ mcpPrompts := csync.NewSlice[uicmd.Command]()
78+ mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
83+ selected: uicmd.SystemCommands,
84+ mcpPrompts: mcpPrompts,
85+ sessionID: sessionID,
89+ help.Styles = com.Styles.DialogHelpStyles()
93+ c.list = list.NewFilterableList()
95+ c.list.SetSelected(0)
97+ c.input = textinput.New()
98+ c.input.SetVirtualCursor(false)
99+ c.input.Placeholder = "Type to filter"
100+ c.input.SetStyles(com.Styles.TextInput)
103+ c.keyMap.Select = key.NewBinding(
104+ key.WithKeys("enter", "ctrl+y"),
105+ key.WithHelp("enter", "confirm"),
107+ c.keyMap.Next = key.NewBinding(
108+ key.WithKeys("down", "ctrl+n"),
109+ key.WithHelp("↓", "next item"),
111+ c.keyMap.Previous = key.NewBinding(
112+ key.WithKeys("up", "ctrl+p"),
113+ key.WithHelp("↑", "previous item"),
115+ c.keyMap.Tab = key.NewBinding(
116+ key.WithKeys("tab"),
117+ key.WithHelp("tab", "switch selection"),
119+ closeKey := CloseKey
120+ closeKey.SetHelp("esc", "cancel")
121+ c.keyMap.Close = closeKey
123+ // Set initial commands
124+ c.setCommandType(c.selected)
129+// SetSize sets the size of the dialog.
130+func (c *Commands) SetSize(width, height int) {
133+ innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
134+ c.input.SetWidth(innerWidth - c.com.Styles.Dialog.InputPrompt.GetHorizontalFrameSize() - 1)
135+ c.list.SetSize(innerWidth, height-6) // (1) title + (3) input + (1) padding + (1) help
136+ c.help.SetWidth(width)
139+// ID implements Dialog.
140+func (c *Commands) ID() string {
144+// Update implements Dialog.
145+func (c *Commands) Update(msg tea.Msg) tea.Cmd {
146+ switch msg := msg.(type) {
147+ case tea.KeyPressMsg:
149+ case key.Matches(msg, c.keyMap.Previous):
152+ c.list.ScrollToSelected()
153+ case key.Matches(msg, c.keyMap.Next):
156+ c.list.ScrollToSelected()
157+ case key.Matches(msg, c.keyMap.Select):
158+ if selectedItem := c.list.SelectedItem(); selectedItem != nil {
159+ if item, ok := selectedItem.(*CommandItem); ok && item != nil {
160+ return item.Cmd.Handler(item.Cmd) // Huh??
163+ case key.Matches(msg, c.keyMap.Tab):
164+ if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
165+ c.selected = c.nextCommandType()
166+ c.setCommandType(c.selected)
170+ c.input, cmd = c.input.Update(msg)
171+ // Update the list filter
172+ c.list.SetFilter(c.input.Value())
179+// ReloadMCPPrompts reloads the MCP prompts.
180+func (c *Commands) ReloadMCPPrompts() tea.Cmd {
181+ c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
182+ // If we're currently viewing MCP prompts, refresh the list
183+ if c.selected == uicmd.MCPPrompts {
184+ c.setCommandType(uicmd.MCPPrompts)
189+// Cursor returns the cursor position relative to the dialog.
190+func (c *Commands) Cursor() *tea.Cursor {
191+ return c.input.Cursor()
194+// View implements [Dialog].
195+func (c *Commands) View() string {
197+ selectedFn := func(t uicmd.CommandType) string {
198+ if t == c.selected {
199+ return "◉ " + t.String()
201+ return "○ " + t.String()
205+ selectedFn(uicmd.SystemCommands),
207+ if len(c.userCmds) > 0 {
208+ parts = append(parts, selectedFn(uicmd.UserCommands))
210+ if c.mcpPrompts.Len() > 0 {
211+ parts = append(parts, selectedFn(uicmd.MCPPrompts))
214+ radio := strings.Join(parts, " ")
215+ radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
216+ if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
220+ titleStyle := t.Dialog.Title
221+ helpStyle := t.Dialog.HelpView
222+ dialogStyle := t.Dialog.View.Width(c.width)
223+ inputStyle := t.Dialog.InputPrompt
224+ helpStyle = helpStyle.Width(c.width - dialogStyle.GetHorizontalFrameSize())
226+ headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
227+ header := common.DialogTitle(t, "Commands", c.width-headerOffset) + radio
228+ title := titleStyle.Render(header)
229+ help := helpStyle.Render(c.help.View(c))
230+ listContent := c.list.Render()
231+ if nlines := lipgloss.Height(listContent); nlines < c.list.Height() {
232+ // pad the list content to avoid jumping when navigating
233+ listContent += strings.Repeat("\n", max(0, c.list.Height()-nlines))
236+ content := strings.Join([]string{
239+ inputStyle.Render(c.input.View()),
246+ return dialogStyle.Render(content)
249+// ShortHelp implements [help.KeyMap].
250+func (c *Commands) ShortHelp() []key.Binding {
251+ upDown := key.NewBinding(
252+ key.WithKeys("up", "down"),
253+ key.WithHelp("↑/↓", "choose"),
255+ return []key.Binding{
263+// FullHelp implements [help.KeyMap].
264+func (c *Commands) FullHelp() [][]key.Binding {
265+ return [][]key.Binding{
266+ {c.keyMap.Select, c.keyMap.Next, c.keyMap.Previous, c.keyMap.Tab},
271+func (c *Commands) nextCommandType() uicmd.CommandType {
273+ case uicmd.SystemCommands:
274+ if len(c.userCmds) > 0 {
275+ return uicmd.UserCommands
277+ if c.mcpPrompts.Len() > 0 {
278+ return uicmd.MCPPrompts
281+ case uicmd.UserCommands:
282+ if c.mcpPrompts.Len() > 0 {
283+ return uicmd.MCPPrompts
286+ case uicmd.MCPPrompts:
287+ return uicmd.SystemCommands
289+ return uicmd.SystemCommands
293+func (c *Commands) setCommandType(commandType uicmd.CommandType) {
294+ c.selected = commandType
296+ var commands []uicmd.Command
298+ case uicmd.SystemCommands:
299+ commands = c.defaultCommands()
300+ case uicmd.UserCommands:
301+ commands = c.userCmds
302+ case uicmd.MCPPrompts:
303+ commands = slices.Collect(c.mcpPrompts.Seq())
306+ commandItems := []list.FilterableItem{}
307+ for _, cmd := range commands {
308+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
311+ c.list.SetItems(commandItems...)
312+ // Reset selection and filter
313+ c.list.SetSelected(0)
314+ c.input.SetValue("")
317+// TODO: Rethink this
318+func (c *Commands) defaultCommands() []uicmd.Command {
319+ commands := []uicmd.Command{
322+ Title: "New Session",
323+ Description: "start a new session",
325+ Handler: func(cmd uicmd.Command) tea.Cmd {
326+ return util.CmdHandler(NewSessionsMsg{})
330+ ID: "switch_session",
331+ Title: "Switch Session",
332+ Description: "Switch to a different session",
334+ Handler: func(cmd uicmd.Command) tea.Cmd {
335+ return util.CmdHandler(SwitchSessionsMsg{})
340+ Title: "Switch Model",
341+ Description: "Switch to a different model",
343+ Handler: func(cmd uicmd.Command) tea.Cmd {
344+ return util.CmdHandler(SwitchModelMsg{})
349+ // Only show compact command if there's an active session
350+ if c.sessionID != "" {
351+ commands = append(commands, uicmd.Command{
353+ Title: "Summarize Session",
354+ Description: "Summarize the current session and create a new one with the summary",
355+ Handler: func(cmd uicmd.Command) tea.Cmd {
356+ return util.CmdHandler(CompactMsg{
357+ SessionID: c.sessionID,
363+ // Add reasoning toggle for models that support it
364+ cfg := c.com.Config()
365+ if agentCfg, ok := cfg.Agents[config.AgentCoder]; ok {
366+ providerCfg := cfg.GetProviderForModel(agentCfg.Model)
367+ model := cfg.GetModelByType(agentCfg.Model)
368+ if providerCfg != nil && model != nil && model.CanReason {
369+ selectedModel := cfg.Models[agentCfg.Model]
371+ // Anthropic models: thinking toggle
372+ if providerCfg.Type == catwalk.TypeAnthropic {
374+ if selectedModel.Think {
377+ commands = append(commands, uicmd.Command{
378+ ID: "toggle_thinking",
379+ Title: status + " Thinking Mode",
380+ Description: "Toggle model thinking for reasoning-capable models",
381+ Handler: func(cmd uicmd.Command) tea.Cmd {
382+ return util.CmdHandler(ToggleThinkingMsg{})
387+ // OpenAI models: reasoning effort dialog
388+ if len(model.ReasoningLevels) > 0 {
389+ commands = append(commands, uicmd.Command{
390+ ID: "select_reasoning_effort",
391+ Title: "Select Reasoning Effort",
392+ Description: "Choose reasoning effort level (low/medium/high)",
393+ Handler: func(cmd uicmd.Command) tea.Cmd {
394+ return util.CmdHandler(OpenReasoningDialogMsg{})
400+ // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
401+ // TODO: Get. Rid. Of. Magic. Numbers!
402+ if c.width > 120 && c.sessionID != "" {
403+ commands = append(commands, uicmd.Command{
404+ ID: "toggle_sidebar",
405+ Title: "Toggle Sidebar",
406+ Description: "Toggle between compact and normal layout",
407+ Handler: func(cmd uicmd.Command) tea.Cmd {
408+ return util.CmdHandler(ToggleCompactModeMsg{})
412+ if c.sessionID != "" {
413+ cfg := c.com.Config()
414+ agentCfg := cfg.Agents[config.AgentCoder]
415+ model := cfg.GetModelByType(agentCfg.Model)
416+ if model.SupportsImages {
417+ commands = append(commands, uicmd.Command{
419+ Title: "Open File Picker",
421+ Description: "Open file picker",
422+ Handler: func(cmd uicmd.Command) tea.Cmd {
423+ return util.CmdHandler(OpenFilePickerMsg{})
429+ // Add external editor command if $EDITOR is available
430+ // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
431+ if os.Getenv("EDITOR") != "" {
432+ commands = append(commands, uicmd.Command{
433+ ID: "open_external_editor",
434+ Title: "Open External Editor",
436+ Description: "Open external editor to compose message",
437+ Handler: func(cmd uicmd.Command) tea.Cmd {
438+ return util.CmdHandler(OpenExternalEditorMsg{})
443+ return append(commands, []uicmd.Command{
446+ Title: "Toggle Yolo Mode",
447+ Description: "Toggle yolo mode",
448+ Handler: func(cmd uicmd.Command) tea.Cmd {
449+ return util.CmdHandler(ToggleYoloModeMsg{})
454+ Title: "Toggle Help",
456+ Description: "Toggle help",
457+ Handler: func(cmd uicmd.Command) tea.Cmd {
458+ return util.CmdHandler(ToggleHelpMsg{})
463+ Title: "Initialize Project",
464+ Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
465+ Handler: func(cmd uicmd.Command) tea.Cmd {
466+ initPrompt, err := agent.InitializePrompt(*c.com.Config())
468+ return util.ReportError(err)
470+ return util.CmdHandler(chat.SendMsg{
478+ Description: "Quit",
480+ Handler: func(cmd uicmd.Command) tea.Cmd {
481+ return util.CmdHandler(QuitMsg{})