sink_commands.go

  1package acp
  2
  3import (
  4	"log/slog"
  5
  6	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  7	"github.com/charmbracelet/crush/internal/config"
  8	"github.com/charmbracelet/crush/internal/pubsub"
  9	"github.com/charmbracelet/crush/internal/uicmd"
 10	"github.com/coder/acp-go-sdk"
 11)
 12
 13const (
 14	systemCommandPrefix = "system:"
 15	mcpCommandPrefix    = "mcp:"
 16)
 17
 18// HandleMCPEvent processes MCP events and republishes commands when prompts
 19// change.
 20func (s *Sink) HandleMCPEvent(event pubsub.Event[mcp.Event]) {
 21	switch event.Payload.Type {
 22	case mcp.EventPromptsListChanged, mcp.EventStateChanged:
 23		s.PublishCommands()
 24	}
 25}
 26
 27// PublishCommands aggregates commands from all sources and sends an
 28// AvailableCommandsUpdate to the ACP client.
 29func (s *Sink) PublishCommands() {
 30	var commands []acp.AvailableCommand
 31
 32	// System/built-in commands.
 33	commands = append(commands, s.builtinCommands()...)
 34
 35	// User and project commands (already prefixed by uicmd).
 36	if userCmds, err := uicmd.LoadCustomCommandsFromConfig(config.Get()); err == nil {
 37		commands = append(commands, translateCommands(userCmds, "")...)
 38	}
 39
 40	// MCP prompts.
 41	mcpCmds := uicmd.LoadMCPPrompts()
 42	commands = append(commands, translateCommands(mcpCmds, mcpCommandPrefix)...)
 43
 44	if err := s.conn.SessionUpdate(s.ctx, acp.SessionNotification{
 45		SessionId: acp.SessionId(s.sessionID),
 46		Update: acp.SessionUpdate{
 47			AvailableCommandsUpdate: &acp.SessionAvailableCommandsUpdate{
 48				AvailableCommands: commands,
 49			},
 50		},
 51	}); err != nil {
 52		slog.Error("Failed to send available commands update", "error", err)
 53	}
 54}
 55
 56// builtinCommands returns ACP-compatible built-in commands.
 57func (s *Sink) builtinCommands() []acp.AvailableCommand {
 58	return []acp.AvailableCommand{
 59		{
 60			Name:        systemCommandPrefix + "new_session",
 61			Description: "Start a new session",
 62		},
 63		{
 64			Name:        systemCommandPrefix + "switch_session",
 65			Description: "Switch to a different session",
 66		},
 67		{
 68			Name:        systemCommandPrefix + "switch_model",
 69			Description: "Switch to a different model",
 70		},
 71		{
 72			Name:        systemCommandPrefix + "summarize",
 73			Description: "Summarize the current session and create a new one with the summary",
 74		},
 75		{
 76			Name:        systemCommandPrefix + "toggle_thinking",
 77			Description: "Toggle model thinking for reasoning-capable models",
 78		},
 79		{
 80			Name:        systemCommandPrefix + "toggle_yolo",
 81			Description: "Toggle yolo mode (auto-approve tool calls)",
 82		},
 83		{
 84			Name:        systemCommandPrefix + "help",
 85			Description: "Show available commands and shortcuts",
 86		},
 87	}
 88}
 89
 90// translateCommands converts uicmd.Command slice to acp.AvailableCommand
 91// slice, optionally adding a prefix.
 92func translateCommands(cmds []uicmd.Command, prefix string) []acp.AvailableCommand {
 93	result := make([]acp.AvailableCommand, 0, len(cmds))
 94	for _, cmd := range cmds {
 95		acpCmd := acp.AvailableCommand{
 96			Name:        prefix + cmd.ID,
 97			Description: cmd.Description,
 98		}
 99
100		// If the command has a title different from ID, use it as a hint.
101		if cmd.Title != "" && cmd.Title != cmd.ID {
102			acpCmd.Input = &acp.AvailableCommandInput{
103				UnstructuredCommandInput: &acp.AvailableCommandUnstructuredCommandInput{
104					Hint: cmd.Title,
105				},
106			}
107		}
108
109		result = append(result, acpCmd)
110	}
111	return result
112}