feat(acp): expose commands via AvailableCommands

Amolith created

Assisted-by: Claude Sonnet 4 via Crush

Change summary

internal/acp/sink.go          |  21 ++++++
internal/acp/sink_commands.go | 112 +++++++++++++++++++++++++++++++++++++
2 files changed, 133 insertions(+)

Detailed changes

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.

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
+}