feat(acp): map tool names to ACP tool kinds

Amolith created

Maps Crush tool names to semantic ACP ToolKinds (read, edit, execute,
search, fetch, other) and extracts file paths from tool input JSON for
richer client rendering.

Assisted-by: Claude Opus 4.5 via Crush

Change summary

internal/acp/sink.go | 65 +++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 61 insertions(+), 4 deletions(-)

Detailed changes

internal/acp/sink.go 🔗

@@ -2,6 +2,7 @@ package acp
 
 import (
 	"context"
+	"encoding/json"
 	"log/slog"
 
 	"github.com/charmbracelet/crush/internal/message"
@@ -216,11 +217,20 @@ func (s *Sink) translateReasoning(msgID string, reasoning message.ReasoningConte
 
 func (s *Sink) translateToolCall(tc message.ToolCall) *acp.SessionUpdate {
 	if !tc.Finished {
-		update := acp.StartToolCall(
-			acp.ToolCallId(tc.ID),
-			tc.Name,
+		opts := []acp.ToolCallStartOpt{
 			acp.WithStartStatus(acp.ToolCallStatusPending),
-		)
+			acp.WithStartKind(toolKind(tc.Name)),
+		}
+
+		// Parse input to extract path and raw input.
+		if input := parseToolInput(tc.Input); input != nil {
+			if input.Path != "" {
+				opts = append(opts, acp.WithStartLocations([]acp.ToolCallLocation{{Path: input.Path}}))
+			}
+			opts = append(opts, acp.WithStartRawInput(input.Raw))
+		}
+
+		update := acp.StartToolCall(acp.ToolCallId(tc.ID), tc.Name, opts...)
 		return &update
 	}
 
@@ -231,6 +241,53 @@ func (s *Sink) translateToolCall(tc message.ToolCall) *acp.SessionUpdate {
 	return &update
 }
 
+// toolInput holds parsed tool call input.
+type toolInput struct {
+	Path string
+	Raw  map[string]any
+}
+
+// parseToolInput extracts path and raw input from JSON tool input.
+func parseToolInput(input string) *toolInput {
+	if input == "" {
+		return nil
+	}
+
+	var raw map[string]any
+	if err := json.Unmarshal([]byte(input), &raw); err != nil {
+		return nil
+	}
+
+	ti := &toolInput{Raw: raw}
+
+	// Extract path from common field names.
+	if path, ok := raw["file_path"].(string); ok {
+		ti.Path = path
+	} else if path, ok := raw["path"].(string); ok {
+		ti.Path = path
+	}
+
+	return ti
+}
+
+// toolKind maps Crush tool names to ACP tool kinds.
+func toolKind(name string) acp.ToolKind {
+	switch name {
+	case "view", "ls", "job_output", "lsp_diagnostics":
+		return acp.ToolKindRead
+	case "edit", "multiedit", "write":
+		return acp.ToolKindEdit
+	case "bash", "job_kill":
+		return acp.ToolKindExecute
+	case "grep", "glob", "lsp_references", "sourcegraph", "web_search":
+		return acp.ToolKindSearch
+	case "fetch", "agentic_fetch", "web_fetch", "download":
+		return acp.ToolKindFetch
+	default:
+		return acp.ToolKindOther
+	}
+}
+
 func (s *Sink) translateToolResult(tr message.ToolResult) *acp.SessionUpdate {
 	status := acp.ToolCallStatusCompleted
 	if tr.IsError {