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}