1package chat
2
3import (
4 "encoding/json"
5 "fmt"
6 "sort"
7 "strings"
8
9 "charm.land/lipgloss/v2"
10 "charm.land/lipgloss/v2/table"
11 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/message"
13 "github.com/charmbracelet/crush/internal/stringext"
14 "github.com/charmbracelet/crush/internal/ui/styles"
15)
16
17// DockerMCPToolMessageItem is a message item that represents a Docker MCP tool call.
18type DockerMCPToolMessageItem struct {
19 *baseToolMessageItem
20}
21
22var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
23
24// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
25func NewDockerMCPToolMessageItem(
26 sty *styles.Styles,
27 toolCall message.ToolCall,
28 result *message.ToolResult,
29 canceled bool,
30) ToolMessageItem {
31 return newBaseToolMessageItem(sty, toolCall, result, &DockerMCPToolRenderContext{}, canceled)
32}
33
34// DockerMCPToolRenderContext renders Docker MCP tool messages.
35type DockerMCPToolRenderContext struct{}
36
37// RenderTool implements the [ToolRenderer] interface.
38func (d *DockerMCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
39 cappedWidth := cappedMessageWidth(width)
40
41 var params map[string]any
42 if err := json.Unmarshal([]byte(opts.ToolCall.Input), ¶ms); err != nil {
43 params = make(map[string]any)
44 }
45
46 tool := strings.TrimPrefix(opts.ToolCall.Name, "mcp_"+config.DockerMCPName+"_")
47
48 mainParam := opts.ToolCall.Input
49 extraArgs := map[string]string{}
50 switch tool {
51 case "mcp-find":
52 if query, ok := params["query"]; ok {
53 if qStr, ok := query.(string); ok {
54 mainParam = qStr
55 }
56 }
57 for k, v := range params {
58 if k == "query" {
59 continue
60 }
61 data, _ := json.Marshal(v)
62 extraArgs[k] = string(data)
63 }
64 case "mcp-add":
65 if name, ok := params["name"]; ok {
66 if nStr, ok := name.(string); ok {
67 mainParam = nStr
68 }
69 }
70 for k, v := range params {
71 if k == "name" {
72 continue
73 }
74 data, _ := json.Marshal(v)
75 extraArgs[k] = string(data)
76 }
77 case "mcp-remove":
78 if name, ok := params["name"]; ok {
79 if nStr, ok := name.(string); ok {
80 mainParam = nStr
81 }
82 }
83 for k, v := range params {
84 if k == "name" {
85 continue
86 }
87 data, _ := json.Marshal(v)
88 extraArgs[k] = string(data)
89 }
90 case "mcp-exec":
91 if name, ok := params["name"]; ok {
92 if nStr, ok := name.(string); ok {
93 mainParam = nStr
94 }
95 }
96 case "mcp-config-set":
97 if server, ok := params["server"]; ok {
98 if sStr, ok := server.(string); ok {
99 mainParam = sStr
100 }
101 }
102 }
103
104 var toolParams []string
105 toolParams = append(toolParams, mainParam)
106 keys := make([]string, 0, len(extraArgs))
107 for k := range extraArgs {
108 keys = append(keys, k)
109 }
110 sort.Strings(keys)
111 for _, k := range keys {
112 toolParams = append(toolParams, k, extraArgs[k])
113 }
114
115 if opts.IsPending() {
116 return pendingTool(sty, d.formatToolName(sty, tool), opts.Anim, false)
117 }
118
119 header := d.makeHeader(sty, tool, cappedWidth, opts, toolParams...)
120 if opts.Compact {
121 return header
122 }
123
124 if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
125 return joinToolParts(header, earlyState)
126 }
127
128 if tool == "mcp-find" {
129 return joinToolParts(header, d.renderMCPServers(sty, opts, cappedWidth))
130 }
131
132 if !opts.HasResult() {
133 return header
134 }
135
136 bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
137 var parts []string
138
139 // Handle text content.
140 if opts.Result.Content != "" {
141 var body string
142 var result json.RawMessage
143 if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
144 prettyResult, err := json.MarshalIndent(result, "", " ")
145 if err == nil {
146 body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
147 } else {
148 body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
149 }
150 } else if looksLikeMarkdown(opts.Result.Content) {
151 body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
152 } else {
153 body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
154 }
155 parts = append(parts, body)
156 }
157
158 // Handle image content.
159 if opts.Result.Data != "" && strings.HasPrefix(opts.Result.MIMEType, "image/") {
160 parts = append(parts, "", toolOutputImageContent(sty, opts.Result.Data, opts.Result.MIMEType))
161 }
162
163 if len(parts) == 0 {
164 return header
165 }
166
167 return joinToolParts(header, strings.Join(parts, "\n"))
168}
169
170// FindMCPResponse represents the response from mcp-find.
171type FindMCPResponse struct {
172 Servers []struct {
173 Name string `json:"name"`
174 Description string `json:"description"`
175 } `json:"servers"`
176}
177
178func (d *DockerMCPToolRenderContext) renderMCPServers(sty *styles.Styles, opts *ToolRenderOpts, width int) string {
179 if !opts.HasResult() || opts.Result.Content == "" {
180 return ""
181 }
182
183 var result FindMCPResponse
184 if err := json.Unmarshal([]byte(opts.Result.Content), &result); err != nil {
185 return toolOutputPlainContent(sty, opts.Result.Content, width-toolBodyLeftPaddingTotal, opts.ExpandedContent)
186 }
187
188 if len(result.Servers) == 0 {
189 return sty.Subtle.Render("No MCP servers found.")
190 }
191
192 bodyWidth := min(120, width) - toolBodyLeftPaddingTotal
193 rows := [][]string{}
194 moreServers := ""
195 for i, server := range result.Servers {
196 if i > 9 {
197 moreServers = sty.Subtle.Render(fmt.Sprintf("... and %d more", len(result.Servers)-10))
198 break
199 }
200 rows = append(rows, []string{sty.Base.Render(server.Name), sty.Subtle.Render(server.Description)})
201 }
202 serverTable := table.New().
203 Wrap(false).
204 BorderTop(false).
205 BorderBottom(false).
206 BorderRight(false).
207 BorderLeft(false).
208 BorderColumn(false).
209 BorderRow(false).
210 StyleFunc(func(row, col int) lipgloss.Style {
211 if row == table.HeaderRow {
212 return lipgloss.NewStyle()
213 }
214 switch col {
215 case 0:
216 return lipgloss.NewStyle().PaddingRight(1)
217 }
218 return lipgloss.NewStyle()
219 }).Rows(rows...).Width(bodyWidth)
220 if moreServers != "" {
221 return sty.Tool.Body.Render(serverTable.Render() + "\n" + moreServers)
222 }
223 return sty.Tool.Body.Render(serverTable.Render())
224}
225
226func (d *DockerMCPToolRenderContext) makeHeader(sty *styles.Styles, tool string, width int, opts *ToolRenderOpts, params ...string) string {
227 if opts.Compact {
228 return d.makeCompactHeader(sty, tool, width, params...)
229 }
230
231 icon := toolIcon(sty, opts.Status)
232 if opts.IsPending() {
233 icon = sty.Tool.IconPending.Render()
234 }
235 prefix := fmt.Sprintf("%s %s ", icon, d.formatToolName(sty, tool))
236 return prefix + toolParamList(sty, params, width-lipgloss.Width(prefix))
237}
238
239func (d *DockerMCPToolRenderContext) formatToolName(sty *styles.Styles, tool string) string {
240 mainTool := "Docker MCP"
241 action := tool
242 actionStyle := sty.Tool.MCPToolName
243 switch tool {
244 case "mcp-exec":
245 action = "Exec"
246 case "mcp-config-set":
247 action = "Config Set"
248 case "mcp-find":
249 action = "Find"
250 case "mcp-add":
251 action = "Add"
252 actionStyle = sty.Tool.DockerMCPActionAdd
253 case "mcp-remove":
254 action = "Remove"
255 actionStyle = sty.Tool.DockerMCPActionDel
256 case "code-mode":
257 action = "Code Mode"
258 default:
259 action = strings.ReplaceAll(tool, "-", " ")
260 action = strings.ReplaceAll(action, "_", " ")
261 action = stringext.Capitalize(action)
262 }
263
264 toolNameStyled := sty.Tool.MCPName.Render(mainTool)
265 arrow := sty.Tool.MCPArrow.String()
266 return fmt.Sprintf("%s %s %s", toolNameStyled, arrow, actionStyle.Render(action))
267}
268
269func (d *DockerMCPToolRenderContext) makeCompactHeader(sty *styles.Styles, tool string, width int, params ...string) string {
270 action := tool
271 switch tool {
272 case "mcp-exec":
273 action = "exec"
274 case "mcp-config-set":
275 action = "config-set"
276 case "mcp-find":
277 action = "find"
278 case "mcp-add":
279 action = "add"
280 case "mcp-remove":
281 action = "remove"
282 case "code-mode":
283 action = "code-mode"
284 default:
285 action = strings.ReplaceAll(tool, "-", " ")
286 action = strings.ReplaceAll(action, "_", " ")
287 }
288
289 name := fmt.Sprintf("Docker MCP: %s", action)
290 return toolHeader(sty, ToolStatusSuccess, name, width, true, params...)
291}
292
293// IsDockerMCPTool returns true if the tool name is a Docker MCP tool.
294func IsDockerMCPTool(name string) bool {
295 return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
296}