@@ -0,0 +1,290 @@
1+package chat
2+
3+import (
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.
17+type DockerMCPToolMessageItem struct {
18+ *baseToolMessageItem
19+}
20+
21+var _ ToolMessageItem = (*DockerMCPToolMessageItem)(nil)
22+
23+// NewDockerMCPToolMessageItem creates a new [DockerMCPToolMessageItem].
24+func 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.
34+type DockerMCPToolRenderContext struct{}
35+
36+// RenderTool implements the [ToolRenderer] interface.
37+func (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, false)
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.
165+type FindMCPResponse struct {
166+ Servers []struct {
167+ Name string `json:"name"`
168+ Description string `json:"description"`
169+ } `json:"servers"`
170+}
171+
172+func (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+
220+func (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+
233+func (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+
263+func (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.
288+func IsDockerMCPTool(name string) bool {
289+ return strings.HasPrefix(name, "mcp_"+config.DockerMCPName+"_")
290+}