@@ -18,6 +18,7 @@ import (
19 "charm.land/bubbles/v2/help"
19 "charm.land/bubbles/v2/help"
20 "charm.land/bubbles/v2/key"
20 "charm.land/bubbles/v2/key"
21+ "charm.land/bubbles/v2/spinner"
21 "charm.land/bubbles/v2/textarea"
22 "charm.land/bubbles/v2/textarea"
22 tea "charm.land/bubbletea/v2"
23 tea "charm.land/bubbletea/v2"
23 "charm.land/lipgloss/v2"
24 "charm.land/lipgloss/v2"
@@ -93,10 +94,19 @@ type (
93 userCommandsLoadedMsg struct {
94 userCommandsLoadedMsg struct {
94 Commands []commands.CustomCommand
95 Commands []commands.CustomCommand
96− // mcpCustomCommandsLoadedMsg is sent when mcp prompts are loaded.
97+ // mcpPromptsLoadedMsg is sent when mcp prompts are loaded.
97− mcpCustomCommandsLoadedMsg struct {
98+ mcpPromptsLoadedMsg struct {
98− Prompts []commands.MCPCustomCommand
99+ Prompts []commands.MCPPrompt
101+ // sendMessageMsg is sent to send a message.
102+ // currently only used for mcp prompts.
103+ sendMessageMsg struct {
105+ Attachments []message.Attachment
108+ // closeDialogMsg is sent to close the current dialog.
109+ closeDialogMsg struct{}
102// UI represents the main user interface model.
112// UI represents the main user interface model.
@@ -167,8 +177,8 @@ type UI struct {
167 sidebarLogo string
177 sidebarLogo string
169 // custom commands & mcp commands
179 // custom commands & mcp commands
170− customCommands []commands.CustomCommand
180+ customCommands []commands.CustomCommand
171− mcpCustomCommands []commands.MCPCustomCommand
181+ mcpPrompts []commands.MCPPrompt
173 // forceCompactMode tracks whether compact mode is forced by user toggle
183 // forceCompactMode tracks whether compact mode is forced by user toggle
174 forceCompactMode bool
184 forceCompactMode bool
@@ -282,15 +292,15 @@ func (m *UI) loadCustomCommands() tea.Cmd {
282// loadMCPrompts loads the MCP prompts asynchronously.
292// loadMCPrompts loads the MCP prompts asynchronously.
283func (m *UI) loadMCPrompts() tea.Cmd {
293func (m *UI) loadMCPrompts() tea.Cmd {
284 return func() tea.Msg {
294 return func() tea.Msg {
285− prompts, err := commands.LoadMCPCustomCommands()
295+ prompts, err := commands.LoadMCPPrompts()
286 if err != nil {
296 if err != nil {
287 slog.Error("failed to load mcp prompts", "error", err)
297 slog.Error("failed to load mcp prompts", "error", err)
289 if prompts == nil {
299 if prompts == nil {
290 // flag them as loaded even if there is none or an error
300 // flag them as loaded even if there is none or an error
291− prompts = []commands.MCPCustomCommand{}
301+ prompts = []commands.MCPPrompt{}
293− return mcpCustomCommandsLoadedMsg{Prompts: prompts}
303+ return mcpPromptsLoadedMsg{Prompts: prompts}
@@ -319,6 +329,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
319 cmds = append(cmds, cmd)
329 cmds = append(cmds, cmd)
332+ case sendMessageMsg:
333+ cmds = append(cmds, m.sendMessage(msg.Content, msg.Attachments...))
322 case userCommandsLoadedMsg:
335 case userCommandsLoadedMsg:
323 m.customCommands = msg.Commands
336 m.customCommands = msg.Commands
324 dia := m.dialog.Dialog(dialog.CommandsID)
337 dia := m.dialog.Dialog(dialog.CommandsID)
@@ -330,8 +343,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
331 commands.SetCustomCommands(m.customCommands)
344 commands.SetCustomCommands(m.customCommands)
333− case mcpCustomCommandsLoadedMsg:
346+ case mcpPromptsLoadedMsg:
334− m.mcpCustomCommands = msg.Prompts
347+ m.mcpPrompts = msg.Prompts
335 dia := m.dialog.Dialog(dialog.CommandsID)
348 dia := m.dialog.Dialog(dialog.CommandsID)
336 if dia == nil {
349 if dia == nil {
@@ -339,9 +352,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
340 commands, ok := dia.(*dialog.Commands)
353 commands, ok := dia.(*dialog.Commands)
342− commands.SetMCPCustomCommands(m.mcpCustomCommands)
355+ commands.SetMCPPrompts(m.mcpPrompts)
358+ case closeDialogMsg:
359+ m.dialog.CloseFrontDialog()
345 case pubsub.Event[message.Message]:
361 case pubsub.Event[message.Message]:
346 // Check if this is a child session message for an agent tool.
362 // Check if this is a child session message for an agent tool.
347 if m.session == nil {
363 if m.session == nil {
@@ -374,7 +390,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
377− if initialized && m.mcpCustomCommands == nil {
393+ if initialized && m.mcpPrompts == nil {
378 cmds = append(cmds, m.loadMCPrompts())
394 cmds = append(cmds, m.loadMCPrompts())
380 case pubsub.Event[permission.PermissionRequest]:
396 case pubsub.Event[permission.PermissionRequest]:
@@ -492,6 +508,14 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
492 cmds = append(cmds, cmd)
508 cmds = append(cmds, cmd)
511+ case spinner.TickMsg:
512+ if m.dialog.HasDialogs() {
514+ if cmd := m.handleDialogMsg(msg); cmd != nil {
515+ cmds = append(cmds, cmd)
495 case tea.KeyPressMsg:
519 case tea.KeyPressMsg:
496 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
520 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
497 cmds = append(cmds, cmd)
521 cmds = append(cmds, cmd)
@@ -645,6 +669,11 @@ func (m *UI) loadNestedToolCalls(items []chat.MessageItem) {
645// if the message is a tool result it will update the corresponding tool call message
669// if the message is a tool result it will update the corresponding tool call message
646func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
670func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
647 var cmds []tea.Cmd
671 var cmds []tea.Cmd
672+ existing := m.chat.MessageItem(msg.ID)
673+ if existing != nil {
674+ // message already exists, skip
648 switch msg.Role {
677 switch msg.Role {
649 case message.User, message.Assistant:
678 case message.User, message.Assistant:
650 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
679 items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
@@ -920,6 +949,44 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
920 case dialog.PermissionDeny:
949 case dialog.PermissionDeny:
921 m.com.App.Permissions.Deny(msg.Permission)
950 m.com.App.Permissions.Deny(msg.Permission)
953+ case dialog.ActionRunCustomCommand:
954+ if len(msg.Arguments) > 0 && msg.Args == nil {
955+ m.dialog.CloseFrontDialog()
956+ argsDialog := dialog.NewArguments(
958+ "Custom Command Arguments",
961+ msg, // Pass the action as the result
963+ m.dialog.OpenDialog(argsDialog)
966+ content := msg.Content
967+ if msg.Args != nil {
968+ content = substituteArgs(content, msg.Args)
970+ cmds = append(cmds, m.sendMessage(content))
971+ m.dialog.CloseFrontDialog()
972+ case dialog.ActionRunMCPPrompt:
973+ if len(msg.Arguments) > 0 && msg.Args == nil {
974+ m.dialog.CloseFrontDialog()
977+ title = "MCP Prompt Arguments"
979+ argsDialog := dialog.NewArguments(
984+ msg, // Pass the action as the result
986+ m.dialog.OpenDialog(argsDialog)
989+ cmds = append(cmds, m.runMCPPrompt(msg.ClientID, msg.PromptID, msg.Args))
924 cmds = append(cmds, uiutil.CmdHandler(msg))
991 cmds = append(cmds, uiutil.CmdHandler(msg))
@@ -927,6 +994,15 @@ func (m *UI) handleDialogMsg(msg tea.Msg) tea.Cmd {
927 return tea.Batch(cmds...)
994 return tea.Batch(cmds...)
997+// substituteArgs replaces $ARG_NAME placeholders in content with actual values.
998+func substituteArgs(content string, args map[string]string) string {
999+ for name, value := range args {
1000+ placeholder := "$" + name
1001+ content = strings.ReplaceAll(content, placeholder, value)
930// openAPIKeyInputDialog opens the API key input dialog.
1006// openAPIKeyInputDialog opens the API key input dialog.
931func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
1007func (m *UI) openAPIKeyInputDialog(provider catwalk.Provider, model config.SelectedModel, modelType config.SelectedModelType) tea.Cmd {
932 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
1008 if m.dialog.ContainsDialog(dialog.APIKeyInputID) {
@@ -1055,7 +1131,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
1056 m.randomizePlaceholders()
1132 m.randomizePlaceholders()
1058− return m.sendMessage(value, attachments)
1134+ return m.sendMessage(value, attachments...)
1059 case key.Matches(msg, m.keyMap.Chat.NewSession):
1135 case key.Matches(msg, m.keyMap.Chat.NewSession):
1060 if m.session == nil || m.session.ID == "" {
1136 if m.session == nil || m.session.ID == "" {
@@ -2013,7 +2089,7 @@ func (m *UI) renderSidebarLogo(width int) {
2015// sendMessage sends a message with the given content and attachments.
2091// sendMessage sends a message with the given content and attachments.
2016−func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
2092+func (m *UI) sendMessage(content string, attachments ...message.Attachment) tea.Cmd {
2017 if m.com.App.AgentCoordinator == nil {
2093 if m.com.App.AgentCoordinator == nil {
2018 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
2094 return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
@@ -2165,7 +2241,7 @@ func (m *UI) openCommandsDialog() tea.Cmd {
2165 sessionID = m.session.ID
2241 sessionID = m.session.ID
2168− commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpCustomCommands)
2244+ commands, err := dialog.NewCommands(m.com, sessionID, m.customCommands, m.mcpPrompts)
2169 if err != nil {
2245 if err != nil {
2170 return uiutil.ReportError(err)
2246 return uiutil.ReportError(err)
@@ -2393,6 +2469,33 @@ func (m *UI) drawSessionDetails(scr uv.Screen, area uv.Rectangle) {
2393 ).Draw(scr, area)
2469 ).Draw(scr, area)
2472+func (m *UI) runMCPPrompt(clientID, promptID string, arguments map[string]string) tea.Cmd {
2473+ load := func() tea.Msg {
2474+ prompt, err := commands.GetMCPPrompt(clientID, promptID, arguments)
2476+ // TODO: make this better
2477+ return uiutil.ReportError(err)()
2483+ return sendMessageMsg{
2489+ if cmd := m.dialog.StartLoading(); cmd != nil {
2490+ cmds = append(cmds, cmd)
2492+ cmds = append(cmds, load, func() tea.Msg {
2493+ return closeDialogMsg{}
2496+ return tea.Sequence(cmds...)
2396// renderLogo renders the Crush logo with the given styles and dimensions.
2499// renderLogo renders the Crush logo with the given styles and dimensions.
2397func renderLogo(t *styles.Styles, compact bool, width int) string {
2500func renderLogo(t *styles.Styles, compact bool, width int) string {
2398 return logo.Render(version.Version, compact, logo.Opts{
2501 return logo.Render(version.Version, compact, logo.Opts{