on main help sign in
Refactor commands (#1847)
commit7a2769c0a3b51fb0bfe6988c43208eb272f03d67 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 13 Jan 2026, 16:26 (9 months ago) parentfedf059
6 files changed+523−233
internal/commands/commands.go+213 internal/ui/dialog/actions.go+20 internal/ui/dialog/commands.go+177−209 internal/ui/dialog/commands_item.go+27−12 internal/ui/model/ui.go+85−12 internal/uicmd/uicmd.go+1
internal/commands/commands.gonew file+213
@@ -0,0 +1,213 @@
1+package commands
2+
3+import (
4+ "io/fs"
5+ "os"
6+ "path/filepath"
7+ "regexp"
8+ "strings"
9+
10+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/home"
13+)
14+
15+var namedArgPattern = regexp.MustCompile(`\$([A-Z][A-Z0-9_]*)`)
16+
17+const (
18+ userCommandPrefix = "user:"
19+ projectCommandPrefix = "project:"
20+)
21+
22+// Argument represents a command argument with its name and required status.
23+type Argument struct {
24+ Name string
25+ Required bool
26+}
27+
28+// MCPCustomCommand represents a custom command loaded from an MCP server.
29+type MCPCustomCommand struct {
30+ ID string
31+ Name string
32+ Client string
33+ Arguments []Argument
34+}
35+
36+// CustomCommand represents a user-defined custom command loaded from markdown files.
37+type CustomCommand struct {
38+ ID string
39+ Name string
40+ Content string
41+ Arguments []Argument
42+}
43+
44+type commandSource struct {
45+ path string
46+ prefix string
47+}
48+
49+// LoadCustomCommands loads custom commands from multiple sources including
50+// XDG config directory, home directory, and project directory.
51+func LoadCustomCommands(cfg *config.Config) ([]CustomCommand, error) {
52+ return loadAll(buildCommandSources(cfg))
53+}
54+
55+// LoadMCPCustomCommands loads custom commands from available MCP servers.
56+func LoadMCPCustomCommands() ([]MCPCustomCommand, error) {
57+ var commands []MCPCustomCommand
58+ for mcpName, prompts := range mcp.Prompts() {
59+ for _, prompt := range prompts {
60+ key := mcpName + ":" + prompt.Name
61+ var args []Argument
62+ for _, arg := range prompt.Arguments {
63+ args = append(args, Argument{Name: arg.Name, Required: arg.Required})
64+ }
65+
66+ commands = append(commands, MCPCustomCommand{
67+ ID: key,
68+ Name: prompt.Name,
69+ Client: mcpName,
70+ Arguments: args,
71+ })
72+ }
73+ }
74+ return commands, nil
75+}
76+
77+func buildCommandSources(cfg *config.Config) []commandSource {
78+ var sources []commandSource
79+
80+ // XDG config directory
81+ if dir := getXDGCommandsDir(); dir != "" {
82+ sources = append(sources, commandSource{
83+ path: dir,
84+ prefix: userCommandPrefix,
85+ })
86+ }
87+
88+ // Home directory
89+ if home := home.Dir(); home != "" {
90+ sources = append(sources, commandSource{
91+ path: filepath.Join(home, ".crush", "commands"),
92+ prefix: userCommandPrefix,
93+ })
94+ }
95+
96+ // Project directory
97+ sources = append(sources, commandSource{
98+ path: filepath.Join(cfg.Options.DataDirectory, "commands"),
99+ prefix: projectCommandPrefix,
100+ })
101+
102+ return sources
103+}
104+
105+func loadAll(sources []commandSource) ([]CustomCommand, error) {
106+ var commands []CustomCommand
107+
108+ for _, source := range sources {
109+ if cmds, err := loadFromSource(source); err == nil {
110+ commands = append(commands, cmds...)
111+ }
112+ }
113+
114+ return commands, nil
115+}
116+
117+func loadFromSource(source commandSource) ([]CustomCommand, error) {
118+ if err := ensureDir(source.path); err != nil {
119+ return nil, err
120+ }
121+
122+ var commands []CustomCommand
123+
124+ err := filepath.WalkDir(source.path, func(path string, d fs.DirEntry, err error) error {
125+ if err != nil || d.IsDir() || !isMarkdownFile(d.Name()) {
126+ return err
127+ }
128+
129+ cmd, err := loadCommand(path, source.path, source.prefix)
130+ if err != nil {
131+ return nil // Skip invalid files
132+ }
133+
134+ commands = append(commands, cmd)
135+ return nil
136+ })
137+
138+ return commands, err
139+}
140+
141+func loadCommand(path, baseDir, prefix string) (CustomCommand, error) {
142+ content, err := os.ReadFile(path)
143+ if err != nil {
144+ return CustomCommand{}, err
145+ }
146+
147+ id := buildCommandID(path, baseDir, prefix)
148+
149+ return CustomCommand{
150+ ID: id,
151+ Name: id,
152+ Content: string(content),
153+ Arguments: extractArgNames(string(content)),
154+ }, nil
155+}
156+
157+func extractArgNames(content string) []Argument {
158+ matches := namedArgPattern.FindAllStringSubmatch(content, -1)
159+ if len(matches) == 0 {
160+ return nil
161+ }
162+
163+ seen := make(map[string]bool)
164+ var args []Argument
165+
166+ for _, match := range matches {
167+ arg := match[1]
168+ if !seen[arg] {
169+ seen[arg] = true
170+ // for normal custom commands, all args are required
171+ args = append(args, Argument{Name: arg, Required: true})
172+ }
173+ }
174+
175+ return args
176+}
177+
178+func buildCommandID(path, baseDir, prefix string) string {
179+ relPath, _ := filepath.Rel(baseDir, path)
180+ parts := strings.Split(relPath, string(filepath.Separator))
181+
182+ // Remove .md extension from last part
183+ if len(parts) > 0 {
184+ lastIdx := len(parts) - 1
185+ parts[lastIdx] = strings.TrimSuffix(parts[lastIdx], filepath.Ext(parts[lastIdx]))
186+ }
187+
188+ return prefix + strings.Join(parts, ":")
189+}
190+
191+func getXDGCommandsDir() string {
192+ xdgHome := os.Getenv("XDG_CONFIG_HOME")
193+ if xdgHome == "" {
194+ if home := home.Dir(); home != "" {
195+ xdgHome = filepath.Join(home, ".config")
196+ }
197+ }
198+ if xdgHome != "" {
199+ return filepath.Join(xdgHome, "crush", "commands")
200+ }
201+ return ""
202+}
203+
204+func ensureDir(path string) error {
205+ if _, err := os.Stat(path); os.IsNotExist(err) {
206+ return os.MkdirAll(path, 0o755)
207+ }
208+ return nil
209+}
210+
211+func isMarkdownFile(name string) bool {
212+ return strings.HasSuffix(strings.ToLower(name), ".md")
213+}
internal/ui/dialog/actions.go+20
@@ -3,6 +3,7 @@ package dialog
33import (
44 tea "charm.land/bubbletea/v2"
55 "github.com/charmbracelet/catwalk/pkg/catwalk"
6+ "github.com/charmbracelet/crush/internal/commands"
67 "github.com/charmbracelet/crush/internal/config"
78 "github.com/charmbracelet/crush/internal/permission"
89 "github.com/charmbracelet/crush/internal/session"
@@ -39,6 +40,8 @@ type (
3940 ActionToggleThinking struct{}
4041 ActionExternalEditor struct{}
4142 ActionToggleYoloMode struct{}
43+ // ActionInitializeProject is a message to initialize a project.
44+ ActionInitializeProject struct{}
4245 ActionSummarize struct {
4346 SessionID string
4447 }
@@ -46,6 +49,23 @@ type (
4649 Permission permission.PermissionRequest
4750 Action PermissionAction
4851 }
52+ // ActionRunCustomCommand is a message to run a custom command.
53+ ActionRunCustomCommand struct {
54+ CommandID string
55+ // Used when running a user-defined command
56+ Content string
57+ // Used when running a prompt from MCP
58+ Client string
59+ }
60+ // ActionOpenCustomCommandArgumentsDialog is a message to open the custom command arguments dialog.
61+ ActionOpenCustomCommandArgumentsDialog struct {
62+ CommandID string
63+ // Used when running a user-defined command
64+ Content string
65+ // Used when running a prompt from MCP
66+ Client string
67+ Arguments []commands.Argument
68+ }
4969)
5070
5171// Messages for API key input dialog.
internal/ui/dialog/commands.go+177−209
@@ -1,9 +1,7 @@
11package dialog
22
33import (
4− "fmt"
54 "os"
6− "slices"
75 "strings"
86
97 "charm.land/bubbles/v2/help"
@@ -12,15 +10,11 @@ import (
1210 tea "charm.land/bubbletea/v2"
1311 "charm.land/lipgloss/v2"
1412 "github.com/charmbracelet/catwalk/pkg/catwalk"
15− "github.com/charmbracelet/crush/internal/agent"
13+ "github.com/charmbracelet/crush/internal/commands"
1614 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/csync"
18− "github.com/charmbracelet/crush/internal/ui/chat"
1915 "github.com/charmbracelet/crush/internal/ui/common"
2016 "github.com/charmbracelet/crush/internal/ui/list"
2117 "github.com/charmbracelet/crush/internal/ui/styles"
22− "github.com/charmbracelet/crush/internal/uicmd"
23− "github.com/charmbracelet/crush/internal/uiutil"
2418 uv "github.com/charmbracelet/ultraviolet"
2519 "github.com/charmbracelet/x/ansi"
2620)
@@ -28,6 +22,20 @@ import (
2822// CommandsID is the identifier for the commands dialog.
2923const CommandsID = "commands"
3024
25+// CommandType represents the type of commands being displayed.
26+type CommandType uint
27+
28+// String returns the string representation of the CommandType.
29+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
30+
31+const sidebarCompactModeBreakpoint = 120
32+
33+const (
34+ SystemCommands CommandType = iota
35+ UserCommands
36+ MCPPrompts
37+)
38+
3139// Commands represents a dialog that shows available commands.
3240type Commands struct {
3341 com *common.Common
@@ -37,39 +45,33 @@ type Commands struct {
3745 Next,
3846 Previous,
3947 Tab,
48+ ShiftTab,
4049 Close key.Binding
4150 }
4251
43− sessionID string // can be empty for non-session-specific commands
44− selected uicmd.CommandType
45− userCmds []uicmd.Command
46− mcpPrompts *csync.Slice[uicmd.Command]
52+ sessionID string // can be empty for non-session-specific commands
53+ selected CommandType
4754
4855 help help.Model
4956 input textinput.Model
5057 list *list.FilterableList
5158
52− width int
59+ windowWidth int
60+
61+ customCommands []commands.CustomCommand
62+ mcpCustomCommands []commands.MCPCustomCommand
5363}
5464
5565var _ Dialog = (*Commands)(nil)
5666
5767// NewCommands creates a new commands dialog.
58−func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
59− commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
60− if err != nil {
61− return nil, err
62− }
63−
64− mcpPrompts := csync.NewSlice[uicmd.Command]()
65− mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
66−
68+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
6769 c := &Commands{
68− com: com,
69− userCmds: commands,
70− selected: uicmd.SystemCommands,
71− mcpPrompts: mcpPrompts,
72− sessionID: sessionID,
70+ com: com,
71+ selected: SystemCommands,
72+ sessionID: sessionID,
73+ customCommands: customCommands,
74+ mcpCustomCommands: mcpCustomCommands,
7375 }
7476
7577 help := help.New()
@@ -96,7 +98,7 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
9698 key.WithHelp("↑/↓", "choose"),
9799 )
98100 c.keyMap.Next = key.NewBinding(
99− key.WithKeys("down", "ctrl+n"),
101+ key.WithKeys("down"),
100102 key.WithHelp("↓", "next item"),
101103 )
102104 c.keyMap.Previous = key.NewBinding(
@@ -107,12 +109,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
107109 key.WithKeys("tab"),
108110 key.WithHelp("tab", "switch selection"),
109111 )
112+ c.keyMap.ShiftTab = key.NewBinding(
113+ key.WithKeys("shift+tab"),
114+ key.WithHelp("shift+tab", "switch selection prev"),
115+ )
110116 closeKey := CloseKey
111117 closeKey.SetHelp("esc", "cancel")
112118 c.keyMap.Close = closeKey
113119
114120 // Set initial commands
115− c.setCommandType(c.selected)
121+ c.setCommandItems(c.selected)
116122
117123 return c, nil
118124}
@@ -150,20 +156,28 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
150156 case key.Matches(msg, c.keyMap.Select):
151157 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
152158 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
153− // TODO: Please unravel this mess later and the Command
154− // Handler design.
155− if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh??
156− return cmd()
157− }
159+ return item.Action()
158160 }
159161 }
160162 case key.Matches(msg, c.keyMap.Tab):
161− if len(c.userCmds) > 0 || c.mcpPrompts.Len() > 0 {
163+ if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
162164 c.selected = c.nextCommandType()
163− c.setCommandType(c.selected)
165+ c.setCommandItems(c.selected)
166+ }
167+ case key.Matches(msg, c.keyMap.ShiftTab):
168+ if len(c.customCommands) > 0 || len(c.mcpCustomCommands) > 0 {
169+ c.selected = c.previousCommandType()
170+ c.setCommandItems(c.selected)
164171 }
165172 default:
166173 var cmd tea.Cmd
174+ for _, item := range c.list.VisibleItems() {
175+ if item, ok := item.(*CommandItem); ok && item != nil {
176+ if msg.String() == item.Shortcut() {
177+ return item.Action()
178+ }
179+ }
180+ }
167181 c.input, cmd = c.input.Update(msg)
168182 value := c.input.Value()
169183 c.list.SetFilter(value)
@@ -175,28 +189,18 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
175189 return nil
176190}
177191
178−// ReloadMCPPrompts reloads the MCP prompts.
179−func (c *Commands) ReloadMCPPrompts() tea.Cmd {
180− c.mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
181− // If we're currently viewing MCP prompts, refresh the list
182− if c.selected == uicmd.MCPPrompts {
183− c.setCommandType(uicmd.MCPPrompts)
184− }
185− return nil
186−}
187−
188192// Cursor returns the cursor position relative to the dialog.
189193func (c *Commands) Cursor() *tea.Cursor {
190194 return InputCursor(c.com.Styles, c.input.Cursor())
191195}
192196
193197// commandsRadioView generates the command type selector radio buttons.
194−func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
198+func commandsRadioView(sty *styles.Styles, selected CommandType, hasUserCmds bool, hasMCPPrompts bool) string {
195199 if !hasUserCmds && !hasMCPPrompts {
196200 return ""
197201 }
198202
199− selectedFn := func(t uicmd.CommandType) string {
203+ selectedFn := func(t CommandType) string {
200204 if t == selected {
201205 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
202206 }
@@ -204,14 +208,14 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
204208 }
205209
206210 parts := []string{
207− selectedFn(uicmd.SystemCommands),
211+ selectedFn(SystemCommands),
208212 }
209213
210214 if hasUserCmds {
211− parts = append(parts, selectedFn(uicmd.UserCommands))
215+ parts = append(parts, selectedFn(UserCommands))
212216 }
213217 if hasMCPPrompts {
214− parts = append(parts, selectedFn(uicmd.MCPPrompts))
218+ parts = append(parts, selectedFn(MCPPrompts))
215219 }
216220
217221 return strings.Join(parts, " ")
@@ -222,7 +226,12 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
222226 t := c.com.Styles
223227 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
224228 height := max(0, min(defaultDialogHeight, area.Dy()))
225− c.width = width
229+ if area.Dx() != c.windowWidth && c.selected == SystemCommands {
230+ // since some items in the list depend on width (e.g. toggle sidebar command),
231+ // we need to reset the command items when width changes
232+ c.setCommandItems(c.selected)
233+ }
234+ c.windowWidth = area.Dx()
226235 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
227236 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
228237 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -233,7 +242,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
233242 c.list.SetSize(innerWidth, height-heightOffset)
234243 c.help.SetWidth(innerWidth)
235244
236− radio := commandsRadioView(t, c.selected, len(c.userCmds) > 0, c.mcpPrompts.Len() > 0)
245+ radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpCustomCommands) > 0)
237246 titleStyle := t.Dialog.Title
238247 dialogStyle := t.Dialog.View.Width(width)
239248 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
@@ -265,99 +274,116 @@ func (c *Commands) FullHelp() [][]key.Binding {
265274 }
266275}
267276
268−func (c *Commands) nextCommandType() uicmd.CommandType {
277+// nextCommandType returns the next command type in the cycle.
278+func (c *Commands) nextCommandType() CommandType {
269279 switch c.selected {
270− case uicmd.SystemCommands:
271− if len(c.userCmds) > 0 {
272− return uicmd.UserCommands
280+ case SystemCommands:
281+ if len(c.customCommands) > 0 {
282+ return UserCommands
273283 }
274− if c.mcpPrompts.Len() > 0 {
275− return uicmd.MCPPrompts
284+ if len(c.mcpCustomCommands) > 0 {
285+ return MCPPrompts
276286 }
277287 fallthrough
278− case uicmd.UserCommands:
279− if c.mcpPrompts.Len() > 0 {
280− return uicmd.MCPPrompts
288+ case UserCommands:
289+ if len(c.mcpCustomCommands) > 0 {
290+ return MCPPrompts
281291 }
282292 fallthrough
283− case uicmd.MCPPrompts:
284− return uicmd.SystemCommands
293+ case MCPPrompts:
294+ return SystemCommands
285295 default:
286− return uicmd.SystemCommands
296+ return SystemCommands
287297 }
288298}
289299
290−func (c *Commands) setCommandType(commandType uicmd.CommandType) {
291− c.selected = commandType
292−
293− var commands []uicmd.Command
300+// previousCommandType returns the previous command type in the cycle.
301+func (c *Commands) previousCommandType() CommandType {
294302 switch c.selected {
295− case uicmd.SystemCommands:
296− commands = c.defaultCommands()
297− case uicmd.UserCommands:
298− commands = c.userCmds
299− case uicmd.MCPPrompts:
300− commands = slices.Collect(c.mcpPrompts.Seq())
303+ case SystemCommands:
304+ if len(c.mcpCustomCommands) > 0 {
305+ return MCPPrompts
306+ }
307+ if len(c.customCommands) > 0 {
308+ return UserCommands
309+ }
310+ return SystemCommands
311+ case UserCommands:
312+ return SystemCommands
313+ case MCPPrompts:
314+ if len(c.customCommands) > 0 {
315+ return UserCommands
316+ }
317+ return SystemCommands
318+ default:
319+ return SystemCommands
301320 }
321+}
322+
323+// setCommandItems sets the command items based on the specified command type.
324+func (c *Commands) setCommandItems(commandType CommandType) {
325+ c.selected = commandType
302326
303327 commandItems := []list.FilterableItem{}
304− for _, cmd := range commands {
305− commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
328+ switch c.selected {
329+ case SystemCommands:
330+ for _, cmd := range c.defaultCommands() {
331+ commandItems = append(commandItems, cmd)
332+ }
333+ case UserCommands:
334+ for _, cmd := range c.customCommands {
335+ var action Action
336+ if len(cmd.Arguments) > 0 {
337+ action = ActionOpenCustomCommandArgumentsDialog{
338+ CommandID: cmd.ID,
339+ Content: cmd.Content,
340+ Arguments: cmd.Arguments,
341+ }
342+ } else {
343+ action = ActionRunCustomCommand{
344+ CommandID: cmd.ID,
345+ Content: cmd.Content,
346+ }
347+ }
348+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
349+ }
350+ case MCPPrompts:
351+ for _, cmd := range c.mcpCustomCommands {
352+ var action Action
353+ if len(cmd.Arguments) > 0 {
354+ action = ActionOpenCustomCommandArgumentsDialog{
355+ CommandID: cmd.ID,
356+ Client: cmd.Client,
357+ Arguments: cmd.Arguments,
358+ }
359+ } else {
360+ action = ActionRunCustomCommand{
361+ CommandID: cmd.ID,
362+ Client: cmd.Client,
363+ }
364+ }
365+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
366+ }
306367 }
307368
308369 c.list.SetItems(commandItems...)
309− c.list.SetSelected(0)
310370 c.list.SetFilter("")
311371 c.list.ScrollToTop()
312372 c.list.SetSelected(0)
313373 c.input.SetValue("")
314374}
315375
316−// TODO: Rethink this
317−func (c *Commands) defaultCommands() []uicmd.Command {
318− commands := []uicmd.Command{
319− {
320− ID: "new_session",
321− Title: "New Session",
322− Description: "start a new session",
323− Shortcut: "ctrl+n",
324− Handler: func(cmd uicmd.Command) tea.Cmd {
325− return uiutil.CmdHandler(ActionNewSession{})
326− },
327− },
328− {
329− ID: "switch_session",
330− Title: "Switch Session",
331− Description: "Switch to a different session",
332− Shortcut: "ctrl+s",
333− Handler: func(cmd uicmd.Command) tea.Cmd {
334− return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
335− },
336− },
337− {
338− ID: "switch_model",
339− Title: "Switch Model",
340− Description: "Switch to a different model",
341− // FIXME: The shortcut might get updated if enhanced keyboard is supported.
342− Shortcut: "ctrl+l",
343− Handler: func(cmd uicmd.Command) tea.Cmd {
344− return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
345− },
346− },
376+// defaultCommands returns the list of default system commands.
377+func (c *Commands) defaultCommands() []*CommandItem {
378+ commands := []*CommandItem{
379+ NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
380+ NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
381+ NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
347382 }
348383
349384 // Only show compact command if there's an active session
350385 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 uiutil.CmdHandler(ActionSummarize{
357− SessionID: c.sessionID,
358− })
359− },
360− })
386+ commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
361387 }
362388
363389 // Add reasoning toggle for models that support it
@@ -374,116 +400,58 @@ func (c *Commands) defaultCommands() []uicmd.Command {
374400 if selectedModel.Think {
375401 status = "Disable"
376402 }
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 uiutil.CmdHandler(ActionToggleThinking{})
383− },
384− })
403+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
385404 }
386405
387406 // OpenAI models: reasoning effort dialog
388407 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 uiutil.CmdHandler(ActionOpenDialog{
395− // TODO: Pass reasoning dialog id
396− })
397− },
398− })
408+ commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
409+ // TODO: Pass in the reasoning effort dialog id
410+ }))
399411 }
400412 }
401413 }
402− // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
403− // TODO: Get. Rid. Of. Magic. Numbers!
404− if c.width > 120 && c.sessionID != "" {
405− commands = append(commands, uicmd.Command{
406− ID: "toggle_sidebar",
407− Title: "Toggle Sidebar",
408− Description: "Toggle between compact and normal layout",
409− Handler: func(cmd uicmd.Command) tea.Cmd {
410− return uiutil.CmdHandler(ActionToggleCompactMode{})
411− },
412− })
414+ // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
415+ if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
416+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
413417 }
414418 if c.sessionID != "" {
415419 cfg := c.com.Config()
416420 agentCfg := cfg.Agents[config.AgentCoder]
417421 model := cfg.GetModelByType(agentCfg.Model)
418422 if model != nil && model.SupportsImages {
419− commands = append(commands, uicmd.Command{
420− ID: "file_picker",
421− Title: "Open File Picker",
422− Shortcut: "ctrl+f",
423− Description: "Open file picker",
424− Handler: func(cmd uicmd.Command) tea.Cmd {
425− return uiutil.CmdHandler(ActionOpenDialog{
426− // TODO: Pass file picker dialog id
427− })
428− },
429− })
423+ commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
424+ // TODO: Pass in the file picker dialog id
425+ }))
430426 }
431427 }
432428
433429 // Add external editor command if $EDITOR is available
434430 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
435431 if os.Getenv("EDITOR") != "" {
436− commands = append(commands, uicmd.Command{
437− ID: "open_external_editor",
438− Title: "Open External Editor",
439− Shortcut: "ctrl+o",
440− Description: "Open external editor to compose message",
441− Handler: func(cmd uicmd.Command) tea.Cmd {
442− return uiutil.CmdHandler(ActionExternalEditor{})
443− },
444− })
432+ commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
445433 }
446434
447− return append(commands, []uicmd.Command{
448− {
449− ID: "toggle_yolo",
450− Title: "Toggle Yolo Mode",
451− Description: "Toggle yolo mode",
452− Handler: func(cmd uicmd.Command) tea.Cmd {
453− return uiutil.CmdHandler(ActionToggleYoloMode{})
454− },
455− },
456− {
457− ID: "toggle_help",
458− Title: "Toggle Help",
459− Shortcut: "ctrl+g",
460− Description: "Toggle help",
461− Handler: func(cmd uicmd.Command) tea.Cmd {
462− return uiutil.CmdHandler(ActionToggleHelp{})
463− },
464− },
465− {
466− ID: "init",
467− Title: "Initialize Project",
468− Description: fmt.Sprintf("Create/Update the %s memory file", config.Get().Options.InitializeAs),
469− Handler: func(cmd uicmd.Command) tea.Cmd {
470− initPrompt, err := agent.InitializePrompt(*c.com.Config())
471− if err != nil {
472− return uiutil.ReportError(err)
473− }
474− return uiutil.CmdHandler(chat.SendMsg{
475− Text: initPrompt,
476− })
477− },
478− },
479− {
480− ID: "quit",
481− Title: "Quit",
482− Description: "Quit",
483− Shortcut: "ctrl+c",
484− Handler: func(cmd uicmd.Command) tea.Cmd {
485− return uiutil.CmdHandler(tea.QuitMsg{})
486− },
487− },
488− }...)
435+ return append(commands,
436+ NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
437+ NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
438+ NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
439+ NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
440+ )
441+}
442+
443+// SetCustomCommands sets the custom commands and refreshes the view if user commands are currently displayed.
444+func (c *Commands) SetCustomCommands(customCommands []commands.CustomCommand) {
445+ c.customCommands = customCommands
446+ if c.selected == UserCommands {
447+ c.setCommandItems(c.selected)
448+ }
449+}
450+
451+// SetMCPCustomCommands sets the MCP custom commands and refreshes the view if MCP prompts are currently displayed.
452+func (c *Commands) SetMCPCustomCommands(mcpCustomCommands []commands.MCPCustomCommand) {
453+ c.mcpCustomCommands = mcpCustomCommands
454+ if c.selected == MCPPrompts {
455+ c.setCommandItems(c.selected)
456+ }
489457}
internal/ui/dialog/commands_item.go+27−12
@@ -2,37 +2,42 @@ package dialog
22
33import (
44 "github.com/charmbracelet/crush/internal/ui/styles"
5− "github.com/charmbracelet/crush/internal/uicmd"
65 "github.com/sahilm/fuzzy"
76)
87
98// CommandItem wraps a uicmd.Command to implement the ListItem interface.
109type CommandItem struct {
11− Cmd uicmd.Command
12− t *styles.Styles
13− m fuzzy.Match
14− cache map[int]string
15− focused bool
10+ id string
11+ title string
12+ shortcut string
13+ action Action
14+ t *styles.Styles
15+ m fuzzy.Match
16+ cache map[int]string
17+ focused bool
1618}
1719
1820var _ ListItem = &CommandItem{}
1921
2022// NewCommandItem creates a new CommandItem.
21−func NewCommandItem(t *styles.Styles, cmd uicmd.Command) *CommandItem {
23+func NewCommandItem(t *styles.Styles, id, title, shortcut string, action Action) *CommandItem {
2224 return &CommandItem{
23− Cmd: cmd,
24− t: t,
25+ id: id,
26+ t: t,
27+ title: title,
28+ shortcut: shortcut,
29+ action: action,
2530 }
2631}
2732
2833// Filter implements ListItem.
2934func (c *CommandItem) Filter() string {
30− return c.Cmd.Title
35+ return c.title
3136}
3237
3338// ID implements ListItem.
3439func (c *CommandItem) ID() string {
35− return c.Cmd.ID
40+ return c.id
3641}
3742
3843// SetFocused implements ListItem.
@@ -49,7 +54,17 @@ func (c *CommandItem) SetMatch(m fuzzy.Match) {
4954 c.m = m
5055}
5156
57+// Action returns the action associated with the command item.
58+func (c *CommandItem) Action() Action {
59+ return c.action
60+}
61+
62+// Shortcut returns the shortcut associated with the command item.
63+func (c *CommandItem) Shortcut() string {
64+ return c.shortcut
65+}
66+
5267// Render implements ListItem.
5368func (c *CommandItem) Render(width int) string {
54− return renderItem(c.t, c.Cmd.Title, c.Cmd.Shortcut, c.focused, width, c.cache, &c.m)
69+ return renderItem(c.t, c.title, c.shortcut, c.focused, width, c.cache, &c.m)
5570}
internal/ui/model/ui.go+85−12
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "image"
8+ "log/slog"
89 "math/rand"
910 "net/http"
1011 "os"
@@ -23,6 +24,7 @@ import (
2324 "github.com/charmbracelet/catwalk/pkg/catwalk"
2425 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2526 "github.com/charmbracelet/crush/internal/app"
27+ "github.com/charmbracelet/crush/internal/commands"
2628 "github.com/charmbracelet/crush/internal/config"
2729 "github.com/charmbracelet/crush/internal/filetracker"
2830 "github.com/charmbracelet/crush/internal/history"
@@ -76,7 +78,18 @@ type openEditorMsg struct {
7678 Text string
7779}
7880
79−type cancelTimerExpiredMsg struct{}
81+type (
82+ // cancelTimerExpiredMsg is sent when the cancel timer expires.
83+ cancelTimerExpiredMsg struct{}
84+ // userCommandsLoadedMsg is sent when user commands are loaded.
85+ userCommandsLoadedMsg struct {
86+ Commands []commands.CustomCommand
87+ }
88+ // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
89+ mcpCustomCommandsLoadedMsg struct {
90+ Prompts []commands.MCPCustomCommand
91+ }
92+)
8093
8194// UI represents the main user interface model.
8295type UI struct {
@@ -144,6 +157,10 @@ type UI struct {
144157
145158 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
146159 sidebarLogo string
160+
161+ // custom commands & mcp commands
162+ customCommands []commands.CustomCommand
163+ mcpCustomCommands []commands.MCPCustomCommand
147164}
148165
149166// New creates a new instance of the [UI] model.
@@ -225,9 +242,37 @@ func (m *UI) Init() tea.Cmd {
225242 if m.QueryVersion {
226243 cmds = append(cmds, tea.RequestTerminalVersion)
227244 }
245+ // load the user commands async
246+ cmds = append(cmds, m.loadCustomCommands())
228247 return tea.Batch(cmds...)
229248}
230249
250+// loadCustomCommands loads the custom commands asynchronously.
251+func (m *UI) loadCustomCommands() tea.Cmd {
252+ return func() tea.Msg {
253+ customCommands, err := commands.LoadCustomCommands(m.com.Config())
254+ if err != nil {
255+ slog.Error("failed to load custom commands", "error", err)
256+ }
257+ return userCommandsLoadedMsg{Commands: customCommands}
258+ }
259+}
260+
261+// loadMCPrompts loads the MCP prompts asynchronously.
262+func (m *UI) loadMCPrompts() tea.Cmd {
263+ return func() tea.Msg {
264+ prompts, err := commands.LoadMCPCustomCommands()
265+ if err != nil {
266+ slog.Error("failed to load mcp prompts", "error", err)
267+ }
268+ if prompts == nil {
269+ // flag them as loaded even if there is none or an error
270+ prompts = []commands.MCPCustomCommand{}
271+ }
272+ return mcpCustomCommandsLoadedMsg{Prompts: prompts}
273+ }
274+}
275+
231276// Update handles updates to the UI model.
232277func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
233278 var cmds []tea.Cmd
@@ -250,6 +295,29 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
250295 cmds = append(cmds, cmd)
251296 }
252297
298+ case userCommandsLoadedMsg:
299+ m.customCommands = msg.Commands
300+ dia := m.dialog.Dialog(dialog.CommandsID)
301+ if dia == nil {
302+ break
303+ }
304+
305+ commands, ok := dia.(*dialog.Commands)
306+ if ok {
307+ commands.SetCustomCommands(m.customCommands)
308+ }
309+ case mcpCustomCommandsLoadedMsg:
310+ m.mcpCustomCommands = msg.Prompts
311+ dia := m.dialog.Dialog(dialog.CommandsID)
312+ if dia == nil {
313+ break
314+ }
315+
316+ commands, ok := dia.(*dialog.Commands)
317+ if ok {
318+ commands.SetMCPCustomCommands(m.mcpCustomCommands)
319+ }
320+
253321 case pubsub.Event[message.Message]:
254322 // Check if this is a child session message for an agent tool.
255323 if m.session == nil {
@@ -274,18 +342,16 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
274342 m.lspStates = app.GetLSPStates()
275343 case pubsub.Event[mcp.Event]:
276344 m.mcpStates = mcp.GetStates()
277− if msg.Type == pubsub.UpdatedEvent && m.dialog.ContainsDialog(dialog.CommandsID) {
278− dia := m.dialog.Dialog(dialog.CommandsID)
279− if dia == nil {
345+ // check if all mcps are initialized
346+ initialized := true
347+ for _, state := range m.mcpStates {
348+ if state.State == mcp.StateStarting {
349+ initialized = false
280350 break
281351 }
282−
283− commands, ok := dia.(*dialog.Commands)
284− if ok {
285− if cmd := commands.ReloadMCPPrompts(); cmd != nil {
286− cmds = append(cmds, cmd)
287− }
288− }
352+ }
353+ if initialized && m.mcpCustomCommands == nil {
354+ cmds = append(cmds, m.loadMCPrompts())
289355 }
290356 case pubsub.Event[permission.PermissionRequest]:
291357 if cmd := m.openPermissionsDialog(msg.Payload); cmd != nil {
@@ -776,6 +842,13 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
776842 m.dialog.CloseDialog(dialog.CommandsID)
777843 case dialog.ActionQuit:
778844 cmds = append(cmds, tea.Quit)
845+ case dialog.ActionInitializeProject:
846+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
847+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before summarizing session..."))
848+ break
849+ }
850+ cmds = append(cmds, m.initializeProject())
851+
779852 case dialog.ActionSelectModel:
780853 if m.com.App.AgentCoordinator.IsBusy() {
781854 cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait..."))
@@ -2001,7 +2074,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
20012074 sessionID = m.session.ID
20022075 }
20032076
2004− commands, err := dialog.NewCommands(m.com, sessionID)
2077+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
20052078 if err != nil {
20062079 return uiutil.ReportError(err)
20072080 }
internal/uicmd/uicmd.go+1
@@ -1,6 +1,7 @@
11// Package uicmd provides functionality to load and handle custom commands
22// from markdown files and MCP prompts.
33// TODO: Move this into internal/ui after refactoring.
4+// TODO: DELETE when we delete the old tui
45package uicmd
56
67import (