on main help sign in
refactor: mcp tool item (#1923)
commitead5bf4857f22e583b78c6b26a4caeea38594460 authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 21 Jan 2026, 15:07 (9 months ago) parent742b4d3
4 files changed+243−43
internal/ui/chat/mcp.go+121 internal/ui/chat/tools.go+12−8 internal/ui/dialog/permissions.go+100−35 internal/ui/styles/styles.go+10
internal/ui/chat/mcp.gonew file+121
@@ -0,0 +1,121 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/stringext"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+)
12+
13+// MCPToolMessageItem is a message item that represents a bash tool call.
14+type MCPToolMessageItem struct {
15+ *baseToolMessageItem
16+}
17+
18+var _ ToolMessageItem = (*MCPToolMessageItem)(nil)
19+
20+// NewMCPToolMessageItem creates a new [MCPToolMessageItem].
21+func NewMCPToolMessageItem(
22+ sty *styles.Styles,
23+ toolCall message.ToolCall,
24+ result *message.ToolResult,
25+ canceled bool,
26+) ToolMessageItem {
27+ return newBaseToolMessageItem(sty, toolCall, result, &MCPToolRenderContext{}, canceled)
28+}
29+
30+// MCPToolRenderContext renders bash tool messages.
31+type MCPToolRenderContext struct{}
32+
33+// RenderTool implements the [ToolRenderer] interface.
34+func (b *MCPToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
35+ cappedWidth := cappedMessageWidth(width)
36+ toolNameParts := strings.SplitN(opts.ToolCall.Name, "_", 3)
37+ if len(toolNameParts) != 3 {
38+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid tool name"}, cappedWidth)
39+ }
40+ mcpName := prettyName(toolNameParts[1])
41+ toolName := prettyName(toolNameParts[2])
42+
43+ mcpName = sty.Tool.MCPName.Render(mcpName)
44+ toolName = sty.Tool.MCPToolName.Render(toolName)
45+
46+ name := fmt.Sprintf("%s %s %s", mcpName, sty.Tool.MCPArrow.String(), toolName)
47+
48+ if opts.IsPending() {
49+ return pendingTool(sty, name, opts.Anim)
50+ }
51+
52+ var params map[string]any
53+ if err := json.Unmarshal([]byte(opts.ToolCall.Input), &params); err != nil {
54+ return toolErrorContent(sty, &message.ToolResult{Content: "Invalid parameters"}, cappedWidth)
55+ }
56+
57+ var toolParams []string
58+ if len(params) > 0 {
59+ parsed, _ := json.Marshal(params)
60+ toolParams = append(toolParams, string(parsed))
61+ }
62+
63+ header := toolHeader(sty, opts.Status, name, cappedWidth, opts.Compact, toolParams...)
64+ if opts.Compact {
65+ return header
66+ }
67+
68+ if earlyState, ok := toolEarlyStateContent(sty, opts, cappedWidth); ok {
69+ return joinToolParts(header, earlyState)
70+ }
71+
72+ if !opts.HasResult() || opts.Result.Content == "" {
73+ return header
74+ }
75+
76+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
77+ // see if the result is json
78+ var result json.RawMessage
79+ var body string
80+ if err := json.Unmarshal([]byte(opts.Result.Content), &result); err == nil {
81+ prettyResult, err := json.MarshalIndent(result, "", " ")
82+ if err == nil {
83+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.json", string(prettyResult), 0, bodyWidth, opts.ExpandedContent))
84+ } else {
85+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
86+ }
87+ } else if looksLikeMarkdown(opts.Result.Content) {
88+ body = sty.Tool.Body.Render(toolOutputCodeContent(sty, "result.md", opts.Result.Content, 0, bodyWidth, opts.ExpandedContent))
89+ } else {
90+ body = sty.Tool.Body.Render(toolOutputPlainContent(sty, opts.Result.Content, bodyWidth, opts.ExpandedContent))
91+ }
92+ return joinToolParts(header, body)
93+}
94+
95+func prettyName(name string) string {
96+ name = strings.ReplaceAll(name, "_", " ")
97+ name = strings.ReplaceAll(name, "-", " ")
98+ return stringext.Capitalize(name)
99+}
100+
101+// looksLikeMarkdown checks if content appears to be markdown by looking for
102+// common markdown patterns.
103+func looksLikeMarkdown(content string) bool {
104+ patterns := []string{
105+ "# ", // headers
106+ "## ", // headers
107+ "**", // bold
108+ "```", // code fence
109+ "- ", // unordered list
110+ "1. ", // ordered list
111+ "> ", // blockquote
112+ "---", // horizontal rule
113+ "***", // horizontal rule
114+ }
115+ for _, p := range patterns {
116+ if strings.Contains(content, p) {
117+ return true
118+ }
119+ }
120+ return false
121+}
internal/ui/chat/tools.go+12−8
@@ -243,14 +243,18 @@ func NewToolMessageItem(
243243 case tools.TodosToolName:
244244 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
245245 default:
246− // TODO: Implement other tool items
247− item = newBaseToolMessageItem(
248− sty,
249− toolCall,
250− result,
251− &DefaultToolRenderContext{},
252− canceled,
253− )
246+ if strings.HasPrefix(toolCall.Name, "mcp_") {
247+ item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
248+ } else {
249+ // TODO: Implement other tool items
250+ item = newBaseToolMessageItem(
251+ sty,
252+ toolCall,
253+ result,
254+ &DefaultToolRenderContext{},
255+ canceled,
256+ )
257+ }
254258 }
255259 item.SetMessageID(messageID)
256260 return item
internal/ui/dialog/permissions.go+100−35
@@ -13,7 +13,9 @@ import (
1313 "github.com/charmbracelet/crush/internal/agent/tools"
1414 "github.com/charmbracelet/crush/internal/fsext"
1515 "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/crush/internal/stringext"
1617 "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/ui/styles"
1719 uv "github.com/charmbracelet/ultraviolet"
1820)
1921
@@ -314,19 +316,19 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
314316 forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
315317
316318 // Calculate dialog dimensions based on fullscreen state and content type.
317− var width, height int
319+ var width, maxHeight int
318320 if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
319321 // Use nearly full window for fullscreen.
320322 width = area.Dx()
321− height = area.Dy()
323+ maxHeight = area.Dy()
322324 } else if p.hasDiffView() {
323325 // Wide for side-by-side diffs, capped for readability.
324326 width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
325− height = int(float64(area.Dy()) * diffSizeRatio)
327+ maxHeight = int(float64(area.Dy()) * diffSizeRatio)
326328 } else {
327329 // Narrower for simple content like commands/URLs.
328330 width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
329− height = int(float64(area.Dy()) * simpleHeightRatio)
331+ maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
330332 }
331333
332334 dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
@@ -341,27 +343,51 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
341343 buttonsHeight := lipgloss.Height(buttons)
342344 helpHeight := lipgloss.Height(helpView)
343345 frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
344− availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight
345346
346347 p.defaultDiffSplitMode = width >= splitModeMinWidth
347348
348− if p.viewport.Width() != contentWidth-1 {
349− // Mark diff content as dirty if width has changed
349+ // Pre-render content to measure its actual height.
350+ renderedContent := p.renderContent(contentWidth)
351+ contentHeight := lipgloss.Height(renderedContent)
352+
353+ // For non-diff views, shrink dialog to fit content if it's smaller than max.
354+ var availableHeight int
355+ if !p.hasDiffView() && !forceFullscreen {
356+ fixedHeight := headerHeight + buttonsHeight + helpHeight + frameHeight
357+ neededHeight := fixedHeight + contentHeight
358+ if neededHeight < maxHeight {
359+ availableHeight = contentHeight
360+ } else {
361+ availableHeight = maxHeight - fixedHeight
362+ }
363+ } else {
364+ availableHeight = maxHeight - headerHeight - buttonsHeight - helpHeight - frameHeight
365+ }
366+
367+ // Determine if scrollbar is needed.
368+ needsScrollbar := p.hasDiffView() || contentHeight > availableHeight
369+ viewportWidth := contentWidth
370+ if needsScrollbar {
371+ viewportWidth = contentWidth - 1 // Reserve space for scrollbar.
372+ }
373+
374+ if p.viewport.Width() != viewportWidth {
375+ // Mark content as dirty if width has changed.
350376 p.viewportDirty = true
377+ renderedContent = p.renderContent(viewportWidth)
351378 }
352379
353380 var content string
354381 var scrollbar string
355− // Non-diff content uses the viewport for scrolling.
356− p.viewport.SetWidth(contentWidth - 1) // -1 for scrollbar
382+ p.viewport.SetWidth(viewportWidth)
357383 p.viewport.SetHeight(availableHeight)
358384 if p.viewportDirty {
359− p.viewport.SetContent(p.renderContent(contentWidth - 1))
385+ p.viewport.SetContent(renderedContent)
360386 p.viewportWidth = p.viewport.Width()
361387 p.viewportDirty = false
362388 }
363389 content = p.viewport.View()
364− if p.canScroll() {
390+ if needsScrollbar {
365391 scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
366392 }
367393
@@ -388,7 +414,7 @@ func (p *Permissions) renderHeader(contentWidth int) string {
388414 title = t.Dialog.Title.Render(title)
389415
390416 // Tool info.
391− toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth)
417+ toolLine := p.renderToolName(contentWidth)
392418 pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
393419
394420 lines := []string{title, "", toolLine, pathLine}
@@ -439,10 +465,33 @@ func (p *Permissions) renderKeyValue(key, value string, width int) string {
439465 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
440466}
441467
468+func (p *Permissions) renderToolName(width int) string {
469+ toolName := p.permission.ToolName
470+
471+ // Check if this is an MCP tool (format: mcp_<mcpname>_<toolname>).
472+ if strings.HasPrefix(toolName, "mcp_") {
473+ parts := strings.SplitN(toolName, "_", 3)
474+ if len(parts) == 3 {
475+ mcpName := prettyName(parts[1])
476+ toolPart := prettyName(parts[2])
477+ toolName = fmt.Sprintf("%s %s %s", mcpName, styles.ArrowRightIcon, toolPart)
478+ }
479+ }
480+
481+ return p.renderKeyValue("Tool", toolName, width)
482+}
483+
484+// prettyName converts snake_case or kebab-case to Title Case.
485+func prettyName(name string) string {
486+ name = strings.ReplaceAll(name, "_", " ")
487+ name = strings.ReplaceAll(name, "-", " ")
488+ return stringext.Capitalize(name)
489+}
490+
442491func (p *Permissions) renderContent(width int) string {
443492 switch p.permission.ToolName {
444493 case tools.BashToolName:
445− return p.renderBashContent()
494+ return p.renderBashContent(width)
446495 case tools.EditToolName:
447496 return p.renderEditContent(width)
448497 case tools.WriteToolName:
@@ -450,27 +499,27 @@ func (p *Permissions) renderContent(width int) string {
450499 case tools.MultiEditToolName:
451500 return p.renderMultiEditContent(width)
452501 case tools.DownloadToolName:
453− return p.renderDownloadContent()
502+ return p.renderDownloadContent(width)
454503 case tools.FetchToolName:
455− return p.renderFetchContent()
504+ return p.renderFetchContent(width)
456505 case tools.AgenticFetchToolName:
457− return p.renderAgenticFetchContent()
506+ return p.renderAgenticFetchContent(width)
458507 case tools.ViewToolName:
459− return p.renderViewContent()
508+ return p.renderViewContent(width)
460509 case tools.LSToolName:
461− return p.renderLSContent()
510+ return p.renderLSContent(width)
462511 default:
463− return p.renderDefaultContent()
512+ return p.renderDefaultContent(width)
464513 }
465514}
466515
467−func (p *Permissions) renderBashContent() string {
516+func (p *Permissions) renderBashContent(width int) string {
468517 params, ok := p.permission.Params.(tools.BashPermissionsParams)
469518 if !ok {
470519 return ""
471520 }
472521
473− return p.com.Styles.Dialog.ContentPanel.Render(params.Command)
522+ return p.renderContentPanel(params.Command, width)
474523}
475524
476525func (p *Permissions) renderEditContent(contentWidth int) string {
@@ -529,7 +578,7 @@ func (p *Permissions) renderDiff(filePath, oldContent, newContent string, conten
529578 return result
530579}
531580
532−func (p *Permissions) renderDownloadContent() string {
581+func (p *Permissions) renderDownloadContent(width int) string {
533582 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
534583 if !ok {
535584 return ""
@@ -540,19 +589,19 @@ func (p *Permissions) renderDownloadContent() string {
540589 content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
541590 }
542591
543− return p.com.Styles.Dialog.ContentPanel.Render(content)
592+ return p.renderContentPanel(content, width)
544593}
545594
546−func (p *Permissions) renderFetchContent() string {
595+func (p *Permissions) renderFetchContent(width int) string {
547596 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
548597 if !ok {
549598 return ""
550599 }
551600
552− return p.com.Styles.Dialog.ContentPanel.Render(params.URL)
601+ return p.renderContentPanel(params.URL, width)
553602}
554603
555−func (p *Permissions) renderAgenticFetchContent() string {
604+func (p *Permissions) renderAgenticFetchContent(width int) string {
556605 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
557606 if !ok {
558607 return ""
@@ -565,10 +614,10 @@ func (p *Permissions) renderAgenticFetchContent() string {
565614 content = fmt.Sprintf("Prompt: %s", params.Prompt)
566615 }
567616
568− return p.com.Styles.Dialog.ContentPanel.Render(content)
617+ return p.renderContentPanel(content, width)
569618}
570619
571−func (p *Permissions) renderViewContent() string {
620+func (p *Permissions) renderViewContent(width int) string {
572621 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
573622 if !ok {
574623 return ""
@@ -582,10 +631,10 @@ func (p *Permissions) renderViewContent() string {
582631 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
583632 }
584633
585− return p.com.Styles.Dialog.ContentPanel.Render(content)
634+ return p.renderContentPanel(content, width)
586635}
587636
588−func (p *Permissions) renderLSContent() string {
637+func (p *Permissions) renderLSContent(width int) string {
589638 params, ok := p.permission.Params.(tools.LSPermissionsParams)
590639 if !ok {
591640 return ""
@@ -596,11 +645,16 @@ func (p *Permissions) renderLSContent() string {
596645 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
597646 }
598647
599− return p.com.Styles.Dialog.ContentPanel.Render(content)
648+ return p.renderContentPanel(content, width)
600649}
601650
602−func (p *Permissions) renderDefaultContent() string {
603− content := p.permission.Description
651+func (p *Permissions) renderDefaultContent(width int) string {
652+ t := p.com.Styles
653+ var content string
654+ // do not add the description for mcp tools
655+ if !strings.HasPrefix(p.permission.ToolName, "mcp_") {
656+ content = p.permission.Description
657+ }
604658
605659 // Pretty-print JSON params if available.
606660 if p.permission.Params != nil {
@@ -614,10 +668,15 @@ func (p *Permissions) renderDefaultContent() string {
614668 var parsed any
615669 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
616670 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
671+ jsonContent := string(b)
672+ highlighted, err := common.SyntaxHighlight(t, jsonContent, "params.json", t.BgSubtle)
673+ if err == nil {
674+ jsonContent = highlighted
675+ }
617676 if content != "" {
618677 content += "\n\n"
619678 }
620− content += string(b)
679+ content += jsonContent
621680 }
622681 } else if paramStr != "" {
623682 if content != "" {
@@ -631,7 +690,13 @@ func (p *Permissions) renderDefaultContent() string {
631690 return ""
632691 }
633692
634− return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content))
693+ return p.renderContentPanel(strings.TrimSpace(content), width)
694+}
695+
696+// renderContentPanel renders content in a panel with the full width.
697+func (p *Permissions) renderContentPanel(content string, width int) string {
698+ panelStyle := p.com.Styles.Dialog.ContentPanel
699+ return panelStyle.Width(width).Render(content)
635700}
636701
637702func (p *Permissions) renderButtons(contentWidth int) string {
internal/ui/styles/styles.go+10
@@ -310,6 +310,11 @@ type Styles struct {
310310 TodoCompletedIcon lipgloss.Style // Completed todo icon
311311 TodoInProgressIcon lipgloss.Style // In-progress todo icon
312312 TodoPendingIcon lipgloss.Style // Pending todo icon
313+
314+ // MCP tools
315+ MCPName lipgloss.Style // The mcp name
316+ MCPToolName lipgloss.Style // The mcp tool name
317+ MCPArrow lipgloss.Style // The mcp arrow icon
313318 }
314319
315320 // Dialog styles
@@ -1130,6 +1135,11 @@ func DefaultStyles() Styles {
11301135 s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
11311136 s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
11321137
1138+ // MCP styles
1139+ s.Tool.MCPName = base.Foreground(blue)
1140+ s.Tool.MCPToolName = base.Foreground(blueDark)
1141+ s.Tool.MCPArrow = base.Foreground(blue).SetString(ArrowRightIcon)
1142+
11331143 // Buttons
11341144 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
11351145 s.ButtonBlur = s.Base.Background(bgSubtle)