feat(acp): implement slash command interception

Amolith created

Intercept slash commands in Prompt handler before reaching LLM:

- /toggle_yolo: toggle auto-approve mode
- /toggle_thinking: toggle Think for Anthropic/Hyper models
- /set_reasoning_effort: set effort level for OpenAI-style models
- /summarize: trigger session summarization

Assisted-by: Claude Sonnet 4 via Crush

Change summary

internal/acp/agent.go | 167 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 167 insertions(+)

Detailed changes

internal/acp/agent.go 🔗

@@ -8,6 +8,8 @@ import (
 	"strings"
 
 	"charm.land/fantasy"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/agent/hyper"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/csync"
@@ -187,6 +189,13 @@ func (a *Agent) Prompt(ctx context.Context, params acp.PromptRequest) (acp.Promp
 		return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil
 	}
 
+	// Check for slash commands before sending to the agent.
+	if strings.HasPrefix(prompt, "/") {
+		if resp, handled := a.handleCommand(ctx, string(params.SessionId), prompt); handled {
+			return resp, nil
+		}
+	}
+
 	// Run the agent.
 	result, err := a.app.AgentCoordinator.Run(ctx, string(params.SessionId), prompt)
 	if err != nil {
@@ -217,6 +226,164 @@ func (a *Agent) Cancel(ctx context.Context, params acp.CancelNotification) error
 	return nil
 }
 
+// handleCommand checks if the prompt is a slash command and handles it.
+// Returns the response and true if handled, otherwise returns an empty
+// response and false.
+func (a *Agent) handleCommand(ctx context.Context, sessionID, prompt string) (acp.PromptResponse, bool) {
+	// Parse command name and args: "/command arg1 arg2".
+	parts := strings.Fields(prompt)
+	if len(parts) == 0 {
+		return acp.PromptResponse{}, false
+	}
+
+	cmd := strings.TrimPrefix(parts[0], "/")
+	args := parts[1:]
+
+	var response string
+	var err error
+
+	switch cmd {
+	case "toggle_yolo":
+		response = a.cmdToggleYolo()
+	case "toggle_thinking":
+		response, err = a.cmdToggleThinking(ctx)
+	case "set_reasoning_effort":
+		response, err = a.cmdSetReasoningEffort(ctx, args)
+	case "summarize":
+		response, err = a.cmdSummarize(ctx, sessionID)
+	default:
+		// Not a recognized command; pass through to agent.
+		return acp.PromptResponse{}, false
+	}
+
+	if err != nil {
+		response = fmt.Sprintf("Error: %v", err)
+	}
+
+	// Send the response as an agent text message.
+	a.sendCommandResponse(ctx, sessionID, response)
+
+	return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, true
+}
+
+// sendCommandResponse sends a text response for a command to the ACP client.
+func (a *Agent) sendCommandResponse(ctx context.Context, sessionID, text string) {
+	update := acp.UpdateAgentMessageText(text)
+	if err := a.conn.SessionUpdate(ctx, acp.SessionNotification{
+		SessionId: acp.SessionId(sessionID),
+		Update:    update,
+	}); err != nil {
+		slog.Error("Failed to send command response", "error", err)
+	}
+}
+
+// cmdToggleYolo toggles auto-approve mode for tool calls.
+func (a *Agent) cmdToggleYolo() string {
+	current := a.app.Permissions.SkipRequests()
+	a.app.Permissions.SetSkipRequests(!current)
+	if !current {
+		return "YOLO mode enabled: tool calls will be auto-approved."
+	}
+	return "YOLO mode disabled: tool calls will require approval."
+}
+
+// cmdToggleThinking toggles thinking mode for Anthropic/Hyper reasoning models.
+func (a *Agent) cmdToggleThinking(ctx context.Context) (string, error) {
+	cfg := config.Get()
+	agentCfg := cfg.Agents[config.AgentCoder]
+
+	// Validate that the current model supports thinking toggle.
+	providerCfg := cfg.GetProviderForModel(agentCfg.Model)
+	model := cfg.GetModelByType(agentCfg.Model)
+	if providerCfg == nil || model == nil {
+		return "", fmt.Errorf("could not determine current model configuration")
+	}
+
+	if !model.CanReason {
+		return "", fmt.Errorf("current model does not support reasoning")
+	}
+
+	// Thinking toggle is only for Anthropic/Hyper models.
+	if providerCfg.Type != catwalk.TypeAnthropic && providerCfg.Type != catwalk.Type(hyper.Name) {
+		return "", fmt.Errorf("toggle_thinking is only supported for Anthropic models; use /set_reasoning_effort for other providers")
+	}
+
+	currentModel := cfg.Models[agentCfg.Model]
+	currentModel.Think = !currentModel.Think
+
+	if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+		return "", fmt.Errorf("failed to update model config: %w", err)
+	}
+
+	// Apply the change to the agent.
+	if err := a.app.UpdateAgentModel(ctx); err != nil {
+		return "", fmt.Errorf("failed to apply model change: %w", err)
+	}
+
+	if currentModel.Think {
+		return "Extended thinking enabled.", nil
+	}
+	return "Extended thinking disabled.", nil
+}
+
+// cmdSummarize triggers session summarization.
+func (a *Agent) cmdSummarize(ctx context.Context, sessionID string) (string, error) {
+	if err := a.app.AgentCoordinator.Summarize(ctx, sessionID); err != nil {
+		return "", fmt.Errorf("summarization failed: %w", err)
+	}
+	return "Session summarized successfully.", nil
+}
+
+// cmdSetReasoningEffort sets the reasoning effort level for OpenAI-style models.
+func (a *Agent) cmdSetReasoningEffort(ctx context.Context, args []string) (string, error) {
+	cfg := config.Get()
+	agentCfg := cfg.Agents[config.AgentCoder]
+	model := cfg.GetModelByType(agentCfg.Model)
+
+	if model == nil || len(model.ReasoningLevels) == 0 {
+		return "", fmt.Errorf("current model does not support reasoning effort levels")
+	}
+
+	if len(args) == 0 {
+		currentModel := cfg.Models[agentCfg.Model]
+		current := currentModel.ReasoningEffort
+		if current == "" {
+			current = "default"
+		}
+		return fmt.Sprintf("Current reasoning effort: %s\nAvailable levels: %s",
+			current, strings.Join(model.ReasoningLevels, ", ")), nil
+	}
+
+	effort := strings.ToLower(args[0])
+
+	// Validate the effort level.
+	valid := false
+	for _, level := range model.ReasoningLevels {
+		if strings.EqualFold(level, effort) {
+			effort = level // Use the canonical casing.
+			valid = true
+			break
+		}
+	}
+	if !valid {
+		return "", fmt.Errorf("invalid reasoning effort %q; valid levels: %s",
+			effort, strings.Join(model.ReasoningLevels, ", "))
+	}
+
+	currentModel := cfg.Models[agentCfg.Model]
+	currentModel.ReasoningEffort = effort
+
+	if err := cfg.UpdatePreferredModel(agentCfg.Model, currentModel); err != nil {
+		return "", fmt.Errorf("failed to update model config: %w", err)
+	}
+
+	if err := a.app.UpdateAgentModel(ctx); err != nil {
+		return "", fmt.Errorf("failed to apply model change: %w", err)
+	}
+
+	return fmt.Sprintf("Reasoning effort set to %q.", effort), nil
+}
+
 // replayMessage sends a historical message to the client via session updates.
 func (a *Agent) replayMessage(ctx context.Context, sessionID string, msg message.Message) error {
 	for _, part := range msg.Parts {