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