diff --git a/internal/acp/sink.go b/internal/acp/sink.go index bf886c770071ae3068dbb8e546f954f604a3db88..c30ec3ce336f4da2366aa11f497cefb98948f292 100644 --- a/internal/acp/sink.go +++ b/internal/acp/sink.go @@ -3,8 +3,10 @@ package acp import ( "context" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/permission" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/coder/acp-go-sdk" ) @@ -84,6 +86,25 @@ func (s *Sink) Start(messages message.Service, permissions permission.Service, s } } }() + + // Subscribe to MCP events to refresh commands when prompts change. + go func() { + mcpCh := mcp.SubscribeEvents(s.ctx) + for { + select { + case event, ok := <-mcpCh: + if !ok { + return + } + s.HandleMCPEvent(pubsub.Event[mcp.Event](event)) + case <-s.ctx.Done(): + return + } + } + }() + + // Publish initial commands. + s.PublishCommands() } // Stop cancels the sink's subscriptions. diff --git a/internal/acp/sink_commands.go b/internal/acp/sink_commands.go new file mode 100644 index 0000000000000000000000000000000000000000..fb5dc43f6130bf35388f025106c18c5f50b0ff7a --- /dev/null +++ b/internal/acp/sink_commands.go @@ -0,0 +1,112 @@ +package acp + +import ( + "log/slog" + + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/pubsub" + "github.com/charmbracelet/crush/internal/uicmd" + "github.com/coder/acp-go-sdk" +) + +const ( + systemCommandPrefix = "system:" + mcpCommandPrefix = "mcp:" +) + +// HandleMCPEvent processes MCP events and republishes commands when prompts +// change. +func (s *Sink) HandleMCPEvent(event pubsub.Event[mcp.Event]) { + switch event.Payload.Type { + case mcp.EventPromptsListChanged, mcp.EventStateChanged: + s.PublishCommands() + } +} + +// PublishCommands aggregates commands from all sources and sends an +// AvailableCommandsUpdate to the ACP client. +func (s *Sink) PublishCommands() { + var commands []acp.AvailableCommand + + // System/built-in commands. + commands = append(commands, s.builtinCommands()...) + + // User and project commands (already prefixed by uicmd). + if userCmds, err := uicmd.LoadCustomCommandsFromConfig(config.Get()); err == nil { + commands = append(commands, translateCommands(userCmds, "")...) + } + + // MCP prompts. + mcpCmds := uicmd.LoadMCPPrompts() + commands = append(commands, translateCommands(mcpCmds, mcpCommandPrefix)...) + + if err := s.conn.SessionUpdate(s.ctx, acp.SessionNotification{ + SessionId: acp.SessionId(s.sessionID), + Update: acp.SessionUpdate{ + AvailableCommandsUpdate: &acp.SessionAvailableCommandsUpdate{ + AvailableCommands: commands, + }, + }, + }); err != nil { + slog.Error("Failed to send available commands update", "error", err) + } +} + +// builtinCommands returns ACP-compatible built-in commands. +func (s *Sink) builtinCommands() []acp.AvailableCommand { + return []acp.AvailableCommand{ + { + Name: systemCommandPrefix + "new_session", + Description: "Start a new session", + }, + { + Name: systemCommandPrefix + "switch_session", + Description: "Switch to a different session", + }, + { + Name: systemCommandPrefix + "switch_model", + Description: "Switch to a different model", + }, + { + Name: systemCommandPrefix + "summarize", + Description: "Summarize the current session and create a new one with the summary", + }, + { + Name: systemCommandPrefix + "toggle_thinking", + Description: "Toggle model thinking for reasoning-capable models", + }, + { + Name: systemCommandPrefix + "toggle_yolo", + Description: "Toggle yolo mode (auto-approve tool calls)", + }, + { + Name: systemCommandPrefix + "help", + Description: "Show available commands and shortcuts", + }, + } +} + +// translateCommands converts uicmd.Command slice to acp.AvailableCommand +// slice, optionally adding a prefix. +func translateCommands(cmds []uicmd.Command, prefix string) []acp.AvailableCommand { + result := make([]acp.AvailableCommand, 0, len(cmds)) + for _, cmd := range cmds { + acpCmd := acp.AvailableCommand{ + Name: prefix + cmd.ID, + Description: cmd.Description, + } + + // If the command has a title different from ID, use it as a hint. + if cmd.Title != "" && cmd.Title != cmd.ID { + acpCmd.Input = &acp.AvailableCommandInput{ + UnstructuredCommandInput: &acp.AvailableCommandUnstructuredCommandInput{ + Hint: cmd.Title, + }, + } + } + + result = append(result, acpCmd) + } + return result +}