From ac8aee71310955ab04446553cb113e0b1814ba36 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 3 Jan 2026 21:22:15 -0700 Subject: [PATCH] feat(acp): implement slash command interception 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 --- internal/acp/agent.go | 167 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) diff --git a/internal/acp/agent.go b/internal/acp/agent.go index c2178a99842980fe91d272fe32e98f583f04c540..7daf7c4f984dd550fd869578421e09147f55eb05 100644 --- a/internal/acp/agent.go +++ b/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 {