@@ -0,0 +1,485 @@
1+package dialog
2+
3+import (
4+ "fmt"
5+ "os"
6+ "slices"
7+ "strings"
8+
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"
23+)
24+
25+// CommandsID is the identifier for the commands dialog.
26+const CommandsID = "commands"
27+
28+// Messages for commands
29+type (
30+ SwitchSessionsMsg struct{}
31+ NewSessionsMsg struct{}
32+ SwitchModelMsg struct{}
33+ QuitMsg struct{}
34+ OpenFilePickerMsg struct{}
35+ ToggleHelpMsg struct{}
36+ ToggleCompactModeMsg struct{}
37+ ToggleThinkingMsg struct{}
38+ OpenReasoningDialogMsg struct{}
39+ OpenExternalEditorMsg struct{}
40+ ToggleYoloModeMsg struct{}
41+ CompactMsg struct {
42+ SessionID string
43+ }
44+)
45+
46+// Commands represents a dialog that shows available commands.
47+type Commands struct {
48+ com *common.Common
49+ keyMap struct {
50+ Select,
51+ Next,
52+ Previous,
53+ Tab,
54+ Close key.Binding
55+ }
56+
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]
61+
62+ help help.Model
63+ input textinput.Model
64+ list *list.FilterableList
65+ width, height int
66+}
67+
68+var _ Dialog = (*Commands)(nil)
69+
70+// NewCommands creates a new commands dialog.
71+func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
72+ commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
73+ if err != nil {
74+ return nil, err
75+ }
76+
77+ mcpPrompts := csync.NewSlice[uicmd.Command]()
78+ mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
79+
80+ c := &Commands{
81+ com: com,
82+ userCmds: commands,
83+ selected: uicmd.SystemCommands,
84+ mcpPrompts: mcpPrompts,
85+ sessionID: sessionID,
86+ }
87+
88+ help := help.New()
89+ help.Styles = com.Styles.DialogHelpStyles()
90+
91+ c.help = help
92+
93+ c.list = list.NewFilterableList()
94+ c.list.Focus()
95+ c.list.SetSelected(0)
96+
97+ c.input = textinput.New()
98+ c.input.SetVirtualCursor(false)
99+ c.input.Placeholder = "Type to filter"
100+ c.input.SetStyles(com.Styles.TextInput)
101+ c.input.Focus()
102+
103+ c.keyMap.Select = key.NewBinding(
104+ key.WithKeys("enter", "ctrl+y"),
105+ key.WithHelp("enter", "confirm"),
106+ )
107+ c.keyMap.Next = key.NewBinding(
108+ key.WithKeys("down", "ctrl+n"),
109+ key.WithHelp("↓", "next item"),
110+ )
111+ c.keyMap.Previous = key.NewBinding(
112+ key.WithKeys("up", "ctrl+p"),
113+ key.WithHelp("↑", "previous item"),
114+ )
115+ c.keyMap.Tab = key.NewBinding(
116+ key.WithKeys("tab"),
117+ key.WithHelp("tab", "switch selection"),
118+ )
119+ closeKey := CloseKey
120+ closeKey.SetHelp("esc", "cancel")
121+ c.keyMap.Close = closeKey
122+
123+ // Set initial commands
124+ c.setCommandType(c.selected)
125+
126+ return c, nil
127+}
128+
129+// SetSize sets the size of the dialog.
130+func (c *Commands) SetSize(width, height int) {
131+ c.width = width
132+ c.height = height
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)
137+}
138+
139+// ID implements Dialog.
140+func (c *Commands) ID() string {
141+ return CommandsID
142+}
143+
144+// Update implements Dialog.
145+func (c *Commands) Update(msg tea.Msg) tea.Cmd {
146+ switch msg := msg.(type) {
147+ case tea.KeyPressMsg:
148+ switch {
149+ case key.Matches(msg, c.keyMap.Previous):
150+ c.list.Focus()
151+ c.list.SelectPrev()
152+ c.list.ScrollToSelected()
153+ case key.Matches(msg, c.keyMap.Next):
154+ c.list.Focus()
155+ c.list.SelectNext()
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??
161+ }
162+ }
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)
167+ }
168+ default:
169+ var cmd tea.Cmd
170+ c.input, cmd = c.input.Update(msg)
171+ // Update the list filter
172+ c.list.SetFilter(c.input.Value())
173+ return cmd
174+ }
175+ }
176+ return nil
177+}
178+
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)
185+ }
186+ return nil
187+}
188+
189+// Cursor returns the cursor position relative to the dialog.
190+func (c *Commands) Cursor() *tea.Cursor {
191+ return c.input.Cursor()
192+}
193+
194+// View implements [Dialog].
195+func (c *Commands) View() string {
196+ t := c.com.Styles
197+ selectedFn := func(t uicmd.CommandType) string {
198+ if t == c.selected {
199+ return "◉ " + t.String()
200+ }
201+ return "○ " + t.String()
202+ }
203+
204+ parts := []string{
205+ selectedFn(uicmd.SystemCommands),
206+ }
207+ if len(c.userCmds) > 0 {
208+ parts = append(parts, selectedFn(uicmd.UserCommands))
209+ }
210+ if c.mcpPrompts.Len() > 0 {
211+ parts = append(parts, selectedFn(uicmd.MCPPrompts))
212+ }
213+
214+ radio := strings.Join(parts, " ")
215+ radio = t.Dialog.Commands.CommandTypeSelector.Render(radio)
216+ if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
217+ radio = " " + radio
218+ }
219+
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())
225+
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))
234+ }
235+
236+ content := strings.Join([]string{
237+ title,
238+ "",
239+ inputStyle.Render(c.input.View()),
240+ "",
241+ c.list.Render(),
242+ "",
243+ help,
244+ }, "\n")
245+
246+ return dialogStyle.Render(content)
247+}
248+
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"),
254+ )
255+ return []key.Binding{
256+ c.keyMap.Tab,
257+ upDown,
258+ c.keyMap.Select,
259+ c.keyMap.Close,
260+ }
261+}
262+
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},
267+ {c.keyMap.Close},
268+ }
269+}
270+
271+func (c *Commands) nextCommandType() uicmd.CommandType {
272+ switch c.selected {
273+ case uicmd.SystemCommands:
274+ if len(c.userCmds) > 0 {
275+ return uicmd.UserCommands
276+ }
277+ if c.mcpPrompts.Len() > 0 {
278+ return uicmd.MCPPrompts
279+ }
280+ fallthrough
281+ case uicmd.UserCommands:
282+ if c.mcpPrompts.Len() > 0 {
283+ return uicmd.MCPPrompts
284+ }
285+ fallthrough
286+ case uicmd.MCPPrompts:
287+ return uicmd.SystemCommands
288+ default:
289+ return uicmd.SystemCommands
290+ }
291+}
292+
293+func (c *Commands) setCommandType(commandType uicmd.CommandType) {
294+ c.selected = commandType
295+
296+ var commands []uicmd.Command
297+ switch c.selected {
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())
304+ }
305+
306+ commandItems := []list.FilterableItem{}
307+ for _, cmd := range commands {
308+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
309+ }
310+
311+ c.list.SetItems(commandItems...)
312+ // Reset selection and filter
313+ c.list.SetSelected(0)
314+ c.input.SetValue("")
315+}
316+
317+// TODO: Rethink this
318+func (c *Commands) defaultCommands() []uicmd.Command {
319+ commands := []uicmd.Command{
320+ {
321+ ID: "new_session",
322+ Title: "New Session",
323+ Description: "start a new session",
324+ Shortcut: "ctrl+n",
325+ Handler: func(cmd uicmd.Command) tea.Cmd {
326+ return util.CmdHandler(NewSessionsMsg{})
327+ },
328+ },
329+ {
330+ ID: "switch_session",
331+ Title: "Switch Session",
332+ Description: "Switch to a different session",
333+ Shortcut: "ctrl+s",
334+ Handler: func(cmd uicmd.Command) tea.Cmd {
335+ return util.CmdHandler(SwitchSessionsMsg{})
336+ },
337+ },
338+ {
339+ ID: "switch_model",
340+ Title: "Switch Model",
341+ Description: "Switch to a different model",
342+ Shortcut: "ctrl+l",
343+ Handler: func(cmd uicmd.Command) tea.Cmd {
344+ return util.CmdHandler(SwitchModelMsg{})
345+ },
346+ },
347+ }
348+
349+ // Only show compact command if there's an active session
350+ if c.sessionID != "" {
351+ commands = append(commands, uicmd.Command{
352+ ID: "Summarize",
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,
358+ })
359+ },
360+ })
361+ }
362+
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]
370+
371+ // Anthropic models: thinking toggle
372+ if providerCfg.Type == catwalk.TypeAnthropic {
373+ status := "Enable"
374+ if selectedModel.Think {
375+ status = "Disable"
376+ }
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{})
383+ },
384+ })
385+ }
386+
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{})
395+ },
396+ })
397+ }
398+ }
399+ }
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{})
409+ },
410+ })
411+ }
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{
418+ ID: "file_picker",
419+ Title: "Open File Picker",
420+ Shortcut: "ctrl+f",
421+ Description: "Open file picker",
422+ Handler: func(cmd uicmd.Command) tea.Cmd {
423+ return util.CmdHandler(OpenFilePickerMsg{})
424+ },
425+ })
426+ }
427+ }
428+
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",
435+ Shortcut: "ctrl+o",
436+ Description: "Open external editor to compose message",
437+ Handler: func(cmd uicmd.Command) tea.Cmd {
438+ return util.CmdHandler(OpenExternalEditorMsg{})
439+ },
440+ })
441+ }
442+
443+ return append(commands, []uicmd.Command{
444+ {
445+ ID: "toggle_yolo",
446+ Title: "Toggle Yolo Mode",
447+ Description: "Toggle yolo mode",
448+ Handler: func(cmd uicmd.Command) tea.Cmd {
449+ return util.CmdHandler(ToggleYoloModeMsg{})
450+ },
451+ },
452+ {
453+ ID: "toggle_help",
454+ Title: "Toggle Help",
455+ Shortcut: "ctrl+g",
456+ Description: "Toggle help",
457+ Handler: func(cmd uicmd.Command) tea.Cmd {
458+ return util.CmdHandler(ToggleHelpMsg{})
459+ },
460+ },
461+ {
462+ ID: "init",
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())
467+ if err != nil {
468+ return util.ReportError(err)
469+ }
470+ return util.CmdHandler(chat.SendMsg{
471+ Text: initPrompt,
472+ })
473+ },
474+ },
475+ {
476+ ID: "quit",
477+ Title: "Quit",
478+ Description: "Quit",
479+ Shortcut: "ctrl+c",
480+ Handler: func(cmd uicmd.Command) tea.Cmd {
481+ return util.CmdHandler(QuitMsg{})
482+ },
483+ },
484+ }...)
485+}