1package acp
2
3import (
4 "encoding/json"
5
6 "github.com/charmbracelet/crush/internal/message"
7 "github.com/coder/acp-go-sdk"
8)
9
10func (s *Sink) translateToolCall(tc message.ToolCall) *acp.SessionUpdate {
11 if !tc.Finished {
12 opts := []acp.ToolCallStartOpt{
13 acp.WithStartStatus(acp.ToolCallStatusPending),
14 acp.WithStartKind(toolKind(tc.Name)),
15 }
16
17 // Parse input to extract path, title, and raw input.
18 title := tc.Name
19 if input := parseToolInput(tc.Input); input != nil {
20 if input.Path != "" {
21 opts = append(opts, acp.WithStartLocations([]acp.ToolCallLocation{{Path: input.Path}}))
22 }
23 if input.Title != "" {
24 title = input.Title
25 }
26 opts = append(opts, acp.WithStartRawInput(input.Raw))
27 }
28
29 update := acp.StartToolCall(acp.ToolCallId(tc.ID), title, opts...)
30 return &update
31 }
32
33 // Tool finished streaming - update with title and input now available.
34 opts := []acp.ToolCallUpdateOpt{
35 acp.WithUpdateStatus(acp.ToolCallStatusInProgress),
36 }
37 if input := parseToolInput(tc.Input); input != nil {
38 if input.Title != "" {
39 opts = append(opts, acp.WithUpdateTitle(input.Title))
40 }
41 if input.Path != "" {
42 opts = append(opts, acp.WithUpdateLocations([]acp.ToolCallLocation{{Path: input.Path}}))
43 }
44 opts = append(opts, acp.WithUpdateRawInput(input.Raw))
45 }
46
47 update := acp.UpdateToolCall(acp.ToolCallId(tc.ID), opts...)
48 return &update
49}
50
51// toolInput holds parsed tool call input.
52type toolInput struct {
53 Path string
54 Title string
55 Raw map[string]any
56}
57
58// parseToolInput extracts path and raw input from JSON tool input.
59func parseToolInput(input string) *toolInput {
60 if input == "" {
61 return nil
62 }
63
64 var raw map[string]any
65 if err := json.Unmarshal([]byte(input), &raw); err != nil {
66 return nil
67 }
68
69 ti := &toolInput{Raw: raw}
70
71 // Extract path from common field names.
72 if path, ok := raw["file_path"].(string); ok {
73 ti.Path = path
74 } else if path, ok := raw["path"].(string); ok {
75 ti.Path = path
76 }
77
78 // Extract title/description for display.
79 if desc, ok := raw["description"].(string); ok {
80 ti.Title = desc
81 }
82
83 return ti
84}
85
86// toolKind maps Crush tool names to ACP tool kinds.
87func toolKind(name string) acp.ToolKind {
88 switch name {
89 case "view", "ls", "job_output", "lsp_diagnostics":
90 return acp.ToolKindRead
91 case "edit", "multiedit", "write":
92 return acp.ToolKindEdit
93 case "bash", "job_kill":
94 return acp.ToolKindExecute
95 case "grep", "glob", "lsp_references", "sourcegraph", "web_search":
96 return acp.ToolKindSearch
97 case "fetch", "agentic_fetch", "web_fetch", "download":
98 return acp.ToolKindFetch
99 default:
100 return acp.ToolKindOther
101 }
102}
103
104// diffMetadata holds fields common to edit tool response metadata.
105type diffMetadata struct {
106 FilePath string `json:"file_path"`
107 OldContent string `json:"old_content"`
108 NewContent string `json:"new_content"`
109}
110
111func (s *Sink) translateToolResult(tr message.ToolResult) *acp.SessionUpdate {
112 status := acp.ToolCallStatusCompleted
113 if tr.IsError {
114 status = acp.ToolCallStatusFailed
115 }
116
117 // For edit tools with metadata, emit diff content.
118 content := []acp.ToolCallContent{acp.ToolContent(acp.TextBlock(tr.Content))}
119 var locations []acp.ToolCallLocation
120
121 if !tr.IsError && tr.Metadata != "" {
122 switch tr.Name {
123 case "edit", "multiedit", "write":
124 var meta diffMetadata
125 if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil && meta.FilePath != "" {
126 content = []acp.ToolCallContent{
127 acp.ToolDiffContent(meta.FilePath, meta.NewContent, meta.OldContent),
128 }
129 }
130 case "view":
131 var meta struct {
132 FilePath string `json:"file_path"`
133 }
134 if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil && meta.FilePath != "" {
135 locations = []acp.ToolCallLocation{{Path: meta.FilePath}}
136 }
137 case "ls":
138 var meta struct {
139 Path string `json:"path"`
140 }
141 if err := json.Unmarshal([]byte(tr.Metadata), &meta); err == nil && meta.Path != "" {
142 locations = []acp.ToolCallLocation{{Path: meta.Path}}
143 }
144 }
145 }
146
147 opts := []acp.ToolCallUpdateOpt{
148 acp.WithUpdateStatus(status),
149 acp.WithUpdateContent(content),
150 }
151 if len(locations) > 0 {
152 opts = append(opts, acp.WithUpdateLocations(locations))
153 }
154
155 update := acp.UpdateToolCall(acp.ToolCallId(tr.ToolCallID), opts...)
156 return &update
157}