@@ -1,9 +1,7 @@
1package dialog
1package dialog
9 "charm.land/bubbles/v2/help"
7 "charm.land/bubbles/v2/help"
@@ -12,15 +10,11 @@ import (
12 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
13 "charm.land/lipgloss/v2"
11 "charm.land/lipgloss/v2"
14 "github.com/charmbracelet/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/catwalk/pkg/catwalk"
15− "github.com/charmbracelet/crush/internal/agent"
13+ "github.com/charmbracelet/crush/internal/commands"
16 "github.com/charmbracelet/crush/internal/config"
14 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/csync"
18− "github.com/charmbracelet/crush/internal/ui/chat"
19 "github.com/charmbracelet/crush/internal/ui/common"
15 "github.com/charmbracelet/crush/internal/ui/common"
20 "github.com/charmbracelet/crush/internal/ui/list"
16 "github.com/charmbracelet/crush/internal/ui/list"
21 "github.com/charmbracelet/crush/internal/ui/styles"
17 "github.com/charmbracelet/crush/internal/ui/styles"
22− "github.com/charmbracelet/crush/internal/uicmd"
23− "github.com/charmbracelet/crush/internal/uiutil"
24 uv "github.com/charmbracelet/ultraviolet"
18 uv "github.com/charmbracelet/ultraviolet"
25 "github.com/charmbracelet/x/ansi"
19 "github.com/charmbracelet/x/ansi"
@@ -28,6 +22,20 @@ import (
28// CommandsID is the identifier for the commands dialog.
22// CommandsID is the identifier for the commands dialog.
29const CommandsID = "commands"
23const CommandsID = "commands"
25+// CommandType represents the type of commands being displayed.
28+// String returns the string representation of the CommandType.
29+func (c CommandType) String() string { return []string{"System", "User", "MCP"}[c] }
31+const sidebarCompactModeBreakpoint = 120
34+ SystemCommands CommandType = iota
31// Commands represents a dialog that shows available commands.
39// Commands represents a dialog that shows available commands.
32type Commands struct {
40type Commands struct {
33 com *common.Common
41 com *common.Common
@@ -37,39 +45,33 @@ type Commands struct {
40 Close key.Binding
49 Close key.Binding
43− sessionID string // can be empty for non-session-specific commands
52+ sessionID string // can be empty for non-session-specific commands
44− selected uicmd.CommandType
53+ selected CommandType
45− userCmds []uicmd.Command
46− mcpPrompts *csync.Slice[uicmd.Command]
48 help help.Model
55 help help.Model
49 input textinput.Model
56 input textinput.Model
50 list *list.FilterableList
57 list *list.FilterableList
52− width int
59+ windowWidth int
61+ customCommands []commands.CustomCommand
62+ mcpCustomCommands []commands.MCPCustomCommand
55var _ Dialog = (*Commands)(nil)
65var _ Dialog = (*Commands)(nil)
57// NewCommands creates a new commands dialog.
67// NewCommands creates a new commands dialog.
58−func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
68+func NewCommands(com *common.Common, sessionID string, customCommands []commands.CustomCommand, mcpCustomCommands []commands.MCPCustomCommand) (*Commands, error) {
59− commands, err := uicmd.LoadCustomCommandsFromConfig(com.Config())
64− mcpPrompts := csync.NewSlice[uicmd.Command]()
65− mcpPrompts.SetSlice(uicmd.LoadMCPPrompts())
67 c := &Commands{
69 c := &Commands{
68− com: com,
70+ com: com,
70− selected: uicmd.SystemCommands,
71+ selected: SystemCommands,
71− mcpPrompts: mcpPrompts,
72+ sessionID: sessionID,
72− sessionID: sessionID,
73+ customCommands: customCommands,
74+ mcpCustomCommands: mcpCustomCommands,
75 help := help.New()
77 help := help.New()
@@ -96,7 +98,7 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
96 key.WithHelp("↑/↓", "choose"),
98 key.WithHelp("↑/↓", "choose"),
98 c.keyMap.Next = key.NewBinding(
100 c.keyMap.Next = key.NewBinding(
99− key.WithKeys("down", "ctrl+n"),
101+ key.WithKeys("down"),
100 key.WithHelp("↓", "next item"),
102 key.WithHelp("↓", "next item"),
102 c.keyMap.Previous = key.NewBinding(
104 c.keyMap.Previous = key.NewBinding(
@@ -107,12 +109,16 @@ func NewCommands(com *common.Common, sessionID string) (*Commands, error) {
107 key.WithKeys("tab"),
109 key.WithKeys("tab"),
108 key.WithHelp("tab", "switch selection"),
110 key.WithHelp("tab", "switch selection"),
112+ c.keyMap.ShiftTab = key.NewBinding(
113+ key.WithKeys("shift+tab"),
114+ key.WithHelp("shift+tab", "switch selection prev"),
110 closeKey := CloseKey
116 closeKey := CloseKey
111 closeKey.SetHelp("esc", "cancel")
117 closeKey.SetHelp("esc", "cancel")
112 c.keyMap.Close = closeKey
118 c.keyMap.Close = closeKey
114 // Set initial commands
120 // Set initial commands
115− c.setCommandType(c.selected)
121+ c.setCommandItems(c.selected)
117 return c, nil
123 return c, nil
@@ -150,20 +156,28 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
150 case key.Matches(msg, c.keyMap.Select):
156 case key.Matches(msg, c.keyMap.Select):
151 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
157 if selectedItem := c.list.SelectedItem(); selectedItem != nil {
152 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
158 if item, ok := selectedItem.(*CommandItem); ok && item != nil {
153− // TODO: Please unravel this mess later and the Command
155− if cmd := item.Cmd.Handler(item.Cmd); cmd != nil { // Huh??
156− return cmd()
159+ return item.Action()
160 case key.Matches(msg, c.keyMap.Tab):
162 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 {
162 c.selected = c.nextCommandType()
164 c.selected = c.nextCommandType()
163− c.setCommandType(c.selected)
165+ c.setCommandItems(c.selected)
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)
166 var cmd tea.Cmd
173 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()
167 c.input, cmd = c.input.Update(msg)
181 c.input, cmd = c.input.Update(msg)
168 value := c.input.Value()
182 value := c.input.Value()
169 c.list.SetFilter(value)
183 c.list.SetFilter(value)
@@ -175,28 +189,18 @@ func (c *Commands) HandleMsg(msg tea.Msg) Action {
175 return nil
189 return nil
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)
188// Cursor returns the cursor position relative to the dialog.
192// Cursor returns the cursor position relative to the dialog.
189func (c *Commands) Cursor() *tea.Cursor {
193func (c *Commands) Cursor() *tea.Cursor {
190 return InputCursor(c.com.Styles, c.input.Cursor())
194 return InputCursor(c.com.Styles, c.input.Cursor())
193// commandsRadioView generates the command type selector radio buttons.
197// 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 {
195 if !hasUserCmds && !hasMCPPrompts {
199 if !hasUserCmds && !hasMCPPrompts {
196 return ""
200 return ""
199− selectedFn := func(t uicmd.CommandType) string {
203+ selectedFn := func(t CommandType) string {
200 if t == selected {
204 if t == selected {
201 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
205 return sty.RadioOn.Padding(0, 1).Render() + sty.HalfMuted.Render(t.String())
@@ -204,14 +208,14 @@ func commandsRadioView(sty *styles.Styles, selected uicmd.CommandType, hasUserCm
206 parts := []string{
210 parts := []string{
207− selectedFn(uicmd.SystemCommands),
211+ selectedFn(SystemCommands),
210 if hasUserCmds {
214 if hasUserCmds {
211− parts = append(parts, selectedFn(uicmd.UserCommands))
215+ parts = append(parts, selectedFn(UserCommands))
213 if hasMCPPrompts {
217 if hasMCPPrompts {
214− parts = append(parts, selectedFn(uicmd.MCPPrompts))
218+ parts = append(parts, selectedFn(MCPPrompts))
217 return strings.Join(parts, " ")
221 return strings.Join(parts, " ")
@@ -222,7 +226,12 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
222 t := c.com.Styles
226 t := c.com.Styles
223 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
227 width := max(0, min(defaultDialogMaxWidth, area.Dx()))
224 height := max(0, min(defaultDialogHeight, area.Dy()))
228 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)
234+ c.windowWidth = area.Dx()
226 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
235 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
227 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
236 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
228 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
237 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -233,7 +242,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
233 c.list.SetSize(innerWidth, height-heightOffset)
242 c.list.SetSize(innerWidth, height-heightOffset)
234 c.help.SetWidth(innerWidth)
243 c.help.SetWidth(innerWidth)
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)
237 titleStyle := t.Dialog.Title
246 titleStyle := t.Dialog.Title
238 dialogStyle := t.Dialog.View.Width(width)
247 dialogStyle := t.Dialog.View.Width(width)
239 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
248 headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
@@ -265,99 +274,116 @@ func (c *Commands) FullHelp() [][]key.Binding {
277+// nextCommandType returns the next command type in the cycle.
268−func (c *Commands) nextCommandType() uicmd.CommandType {
278+func (c *Commands) nextCommandType() CommandType {
269 switch c.selected {
279 switch c.selected {
270− case uicmd.SystemCommands:
280+ case SystemCommands:
271− if len(c.userCmds) > 0 {
281+ if len(c.customCommands) > 0 {
272− return uicmd.UserCommands
282+ return UserCommands
274− if c.mcpPrompts.Len() > 0 {
284+ if len(c.mcpCustomCommands) > 0 {
275− return uicmd.MCPPrompts
285+ return MCPPrompts
277 fallthrough
287 fallthrough
278− case uicmd.UserCommands:
288+ case UserCommands:
279− if c.mcpPrompts.Len() > 0 {
289+ if len(c.mcpCustomCommands) > 0 {
280− return uicmd.MCPPrompts
290+ return MCPPrompts
282 fallthrough
292 fallthrough
283− case uicmd.MCPPrompts:
293+ case MCPPrompts:
284− return uicmd.SystemCommands
294+ return SystemCommands
286− return uicmd.SystemCommands
296+ return SystemCommands
300+// previousCommandType returns the previous command type in the cycle.
290−func (c *Commands) setCommandType(commandType uicmd.CommandType) {
301+func (c *Commands) previousCommandType() CommandType {
291− c.selected = commandType
293− var commands []uicmd.Command
294 switch c.selected {
302 switch c.selected {
295− case uicmd.SystemCommands:
303+ case SystemCommands:
296− commands = c.defaultCommands()
304+ if len(c.mcpCustomCommands) > 0 {
307+ if len(c.customCommands) > 0 {
310+ return SystemCommands
297− case uicmd.UserCommands:
311+ case UserCommands:
298− commands = c.userCmds
312+ return SystemCommands
299− case uicmd.MCPPrompts:
313+ case MCPPrompts:
300− commands = slices.Collect(c.mcpPrompts.Seq())
314+ if len(c.customCommands) > 0 {
317+ return SystemCommands
319+ return SystemCommands
323+// setCommandItems sets the command items based on the specified command type.
324+func (c *Commands) setCommandItems(commandType CommandType) {
325+ c.selected = commandType
303 commandItems := []list.FilterableItem{}
327 commandItems := []list.FilterableItem{}
329+ case SystemCommands:
330+ for _, cmd := range c.defaultCommands() {
331+ commandItems = append(commandItems, cmd)
304− for _, cmd := range commands {
334+ for _, cmd := range c.customCommands {
336+ if len(cmd.Arguments) > 0 {
337+ action = ActionOpenCustomCommandArgumentsDialog{
339+ Content: cmd.Content,
340+ Arguments: cmd.Arguments,
343+ action = ActionRunCustomCommand{
345+ Content: cmd.Content,
348+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "custom_"+cmd.ID, cmd.Name, "", action))
351+ for _, cmd := range c.mcpCustomCommands {
353+ if len(cmd.Arguments) > 0 {
354+ action = ActionOpenCustomCommandArgumentsDialog{
357+ Arguments: cmd.Arguments,
360+ action = ActionRunCustomCommand{
305− commandItems = append(commandItems, NewCommandItem(c.com.Styles, cmd))
365+ commandItems = append(commandItems, NewCommandItem(c.com.Styles, "mcp_"+cmd.ID, cmd.Name, "", action))
308 c.list.SetItems(commandItems...)
369 c.list.SetItems(commandItems...)
309− c.list.SetSelected(0)
310 c.list.SetFilter("")
370 c.list.SetFilter("")
311 c.list.ScrollToTop()
371 c.list.ScrollToTop()
312 c.list.SetSelected(0)
372 c.list.SetSelected(0)
313 c.input.SetValue("")
373 c.input.SetValue("")
316−// TODO: Rethink this
376+// defaultCommands returns the list of default system commands.
317−func (c *Commands) defaultCommands() []uicmd.Command {
377+func (c *Commands) defaultCommands() []*CommandItem {
318− commands := []uicmd.Command{
378+ commands := []*CommandItem{
319− {
379+ NewCommandItem(c.com.Styles, "new_session", "New Session", "ctrl+n", ActionNewSession{}),
321− Title: "New Session",
322− Description: "start a new session",
324− Handler: func(cmd uicmd.Command) tea.Cmd {
325− return uiutil.CmdHandler(ActionNewSession{})
329− ID: "switch_session",
330− Title: "Switch Session",
331− Description: "Switch to a different session",
333− Handler: func(cmd uicmd.Command) tea.Cmd {
334− return uiutil.CmdHandler(ActionOpenDialog{SessionsID})
380+ NewCommandItem(c.com.Styles, "switch_session", "Switch Session", "ctrl+s", ActionOpenDialog{SessionsID}),
335− },
381+ NewCommandItem(c.com.Styles, "switch_model", "Switch Model", "ctrl+l", ActionOpenDialog{ModelsID}),
339− Title: "Switch Model",
340− Description: "Switch to a different model",
341− // FIXME: The shortcut might get updated if enhanced keyboard is supported.
343− Handler: func(cmd uicmd.Command) tea.Cmd {
344− return uiutil.CmdHandler(ActionOpenDialog{ModelsID})
349 // Only show compact command if there's an active session
384 // Only show compact command if there's an active session
350 if c.sessionID != "" {
385 if c.sessionID != "" {
351− commands = append(commands, uicmd.Command{
386+ commands = append(commands, NewCommandItem(c.com.Styles, "summarize", "Summarize Session", "", ActionSummarize{SessionID: c.sessionID}))
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,
363 // Add reasoning toggle for models that support it
389 // Add reasoning toggle for models that support it
@@ -374,116 +400,58 @@ func (c *Commands) defaultCommands() []uicmd.Command {
374 if selectedModel.Think {
400 if selectedModel.Think {
375 status = "Disable"
401 status = "Disable"
377− commands = append(commands, uicmd.Command{
403+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_thinking", status+" Thinking Mode", "", ActionToggleThinking{}))
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{})
387 // OpenAI models: reasoning effort dialog
406 // OpenAI models: reasoning effort dialog
388 if len(model.ReasoningLevels) > 0 {
407 if len(model.ReasoningLevels) > 0 {
389− commands = append(commands, uicmd.Command{
408+ commands = append(commands, NewCommandItem(c.com.Styles, "select_reasoning_effort", "Select Reasoning Effort", "", ActionOpenDialog{
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
409+ // TODO: Pass in the reasoning effort dialog id
402− // Only show toggle compact mode command if window width is larger than compact breakpoint (90)
414+ // Only show toggle compact mode command if window width is larger than compact breakpoint (120)
403− // TODO: Get. Rid. Of. Magic. Numbers!
415+ if c.windowWidth > sidebarCompactModeBreakpoint && c.sessionID != "" {
404− if c.width > 120 && c.sessionID != "" {
416+ commands = append(commands, NewCommandItem(c.com.Styles, "toggle_sidebar", "Toggle Sidebar", "", ActionToggleCompactMode{}))
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{})
414 if c.sessionID != "" {
418 if c.sessionID != "" {
415 cfg := c.com.Config()
419 cfg := c.com.Config()
416 agentCfg := cfg.Agents[config.AgentCoder]
420 agentCfg := cfg.Agents[config.AgentCoder]
417 model := cfg.GetModelByType(agentCfg.Model)
421 model := cfg.GetModelByType(agentCfg.Model)
418 if model != nil && model.SupportsImages {
422 if model != nil && model.SupportsImages {
419− commands = append(commands, uicmd.Command{
423+ commands = append(commands, NewCommandItem(c.com.Styles, "file_picker", "Open File Picker", "ctrl+f", ActionOpenDialog{
421− Title: "Open File Picker",
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
424+ // TODO: Pass in the file picker dialog id
433 // Add external editor command if $EDITOR is available
429 // Add external editor command if $EDITOR is available
434 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
430 // TODO: Use [tea.EnvMsg] to get environment variable instead of os.Getenv
435 if os.Getenv("EDITOR") != "" {
431 if os.Getenv("EDITOR") != "" {
436− commands = append(commands, uicmd.Command{
432+ commands = append(commands, NewCommandItem(c.com.Styles, "open_external_editor", "Open External Editor", "ctrl+o", ActionExternalEditor{}))
437− ID: "open_external_editor",
438− Title: "Open External Editor",
440− Description: "Open external editor to compose message",
441− Handler: func(cmd uicmd.Command) tea.Cmd {
442− return uiutil.CmdHandler(ActionExternalEditor{})
447− return append(commands, []uicmd.Command{
435+ return append(commands,
448− {
436+ NewCommandItem(c.com.Styles, "toggle_yolo", "Toggle Yolo Mode", "", ActionToggleYoloMode{}),
449− ID: "toggle_yolo",
437+ NewCommandItem(c.com.Styles, "toggle_help", "Toggle Help", "ctrl+g", ActionToggleHelp{}),
450− Title: "Toggle Yolo Mode",
438+ NewCommandItem(c.com.Styles, "init", "Initialize Project", "", ActionInitializeProject{}),
451− Description: "Toggle yolo mode",
439+ NewCommandItem(c.com.Styles, "quit", "Quit", "ctrl+c", tea.QuitMsg{}),
452− Handler: func(cmd uicmd.Command) tea.Cmd {
440+ )
453− return uiutil.CmdHandler(ActionToggleYoloMode{})
458− Title: "Toggle Help",
460− Description: "Toggle help",
461− Handler: func(cmd uicmd.Command) tea.Cmd {
462− return uiutil.CmdHandler(ActionToggleHelp{})
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())
472− return uiutil.ReportError(err)
474− return uiutil.CmdHandler(chat.SendMsg{
482− Description: "Quit",
484− Handler: func(cmd uicmd.Command) tea.Cmd {
485− return uiutil.CmdHandler(tea.QuitMsg{})
488− }...)
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)
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)