on main help sign in
refactor: mcp tool item (#1923)
ead5bf4Kujtim Hoxha
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(
243 case tools.TodosToolName:
243 case tools.TodosToolName:
244 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
244 item = NewTodosToolMessageItem(sty, toolCall, result, canceled)
245 default:
245 default:
246+ if strings.HasPrefix(toolCall.Name, "mcp_") {
247+ item = NewMCPToolMessageItem(sty, toolCall, result, canceled)
248+ } else {
246− // TODO: Implement other tool items
249+ // TODO: Implement other tool items
247− item = newBaseToolMessageItem(
250+ item = newBaseToolMessageItem(
248− sty,
251+ sty,
249− toolCall,
252+ toolCall,
250− result,
253+ result,
251− &DefaultToolRenderContext{},
254+ &DefaultToolRenderContext{},
252− canceled,
255+ canceled,
253− )
256+ )
257+ }
254 }
258 }
255 item.SetMessageID(messageID)
259 item.SetMessageID(messageID)
256 return item
260 return item
internal/ui/dialog/permissions.go+100−35
@@ -13,7 +13,9 @@ import (
13 "github.com/charmbracelet/crush/internal/agent/tools"
13 "github.com/charmbracelet/crush/internal/agent/tools"
14 "github.com/charmbracelet/crush/internal/fsext"
14 "github.com/charmbracelet/crush/internal/fsext"
15 "github.com/charmbracelet/crush/internal/permission"
15 "github.com/charmbracelet/crush/internal/permission"
16+ "github.com/charmbracelet/crush/internal/stringext"
16 "github.com/charmbracelet/crush/internal/ui/common"
17 "github.com/charmbracelet/crush/internal/ui/common"
18+ "github.com/charmbracelet/crush/internal/ui/styles"
17 uv "github.com/charmbracelet/ultraviolet"
19 uv "github.com/charmbracelet/ultraviolet"
18)
20)
19
21
@@ -314,19 +316,19 @@ func (p *Permissions) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
314 forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
316 forceFullscreen := area.Dx() <= minWindowWidth || area.Dy() <= minWindowHeight
315
317
316 // Calculate dialog dimensions based on fullscreen state and content type.
318 // Calculate dialog dimensions based on fullscreen state and content type.
317− var width, height int
319+ var width, maxHeight int
318 if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
320 if forceFullscreen || (p.fullscreen && p.hasDiffView()) {
319 // Use nearly full window for fullscreen.
321 // Use nearly full window for fullscreen.
320 width = area.Dx()
322 width = area.Dx()
321− height = area.Dy()
323+ maxHeight = area.Dy()
322 } else if p.hasDiffView() {
324 } else if p.hasDiffView() {
323 // Wide for side-by-side diffs, capped for readability.
325 // Wide for side-by-side diffs, capped for readability.
324 width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
326 width = min(int(float64(area.Dx())*diffSizeRatio), diffMaxWidth)
325− height = int(float64(area.Dy()) * diffSizeRatio)
327+ maxHeight = int(float64(area.Dy()) * diffSizeRatio)
326 } else {
328 } else {
327 // Narrower for simple content like commands/URLs.
329 // Narrower for simple content like commands/URLs.
328 width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
330 width = min(int(float64(area.Dx())*simpleSizeRatio), simpleMaxWidth)
329− height = int(float64(area.Dy()) * simpleHeightRatio)
331+ maxHeight = int(float64(area.Dy()) * simpleHeightRatio)
330 }
332 }
331
333
332 dialogStyle := t.Dialog.View.Width(width).Padding(0, 1)
334 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 {
341 buttonsHeight := lipgloss.Height(buttons)
343 buttonsHeight := lipgloss.Height(buttons)
342 helpHeight := lipgloss.Height(helpView)
344 helpHeight := lipgloss.Height(helpView)
343 frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
345 frameHeight := dialogStyle.GetVerticalFrameSize() + layoutSpacingLines
344− availableHeight := height - headerHeight - buttonsHeight - helpHeight - frameHeight
345
346
346 p.defaultDiffSplitMode = width >= splitModeMinWidth
347 p.defaultDiffSplitMode = width >= splitModeMinWidth
347
348
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+
348− if p.viewport.Width() != contentWidth-1 {
374+ if p.viewport.Width() != viewportWidth {
349− // Mark diff content as dirty if width has changed
375+ // Mark content as dirty if width has changed.
350 p.viewportDirty = true
376 p.viewportDirty = true
377+ renderedContent = p.renderContent(viewportWidth)
351 }
378 }
352
379
353 var content string
380 var content string
354 var scrollbar string
381 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)
357 p.viewport.SetHeight(availableHeight)
383 p.viewport.SetHeight(availableHeight)
358 if p.viewportDirty {
384 if p.viewportDirty {
359− p.viewport.SetContent(p.renderContent(contentWidth - 1))
385+ p.viewport.SetContent(renderedContent)
360 p.viewportWidth = p.viewport.Width()
386 p.viewportWidth = p.viewport.Width()
361 p.viewportDirty = false
387 p.viewportDirty = false
362 }
388 }
363 content = p.viewport.View()
389 content = p.viewport.View()
364− if p.canScroll() {
390+ if needsScrollbar {
365 scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
391 scrollbar = common.Scrollbar(t, availableHeight, p.viewport.TotalLineCount(), availableHeight, p.viewport.YOffset())
366 }
392 }
367
393
@@ -388,7 +414,7 @@ func (p *Permissions) renderHeader(contentWidth int) string {
388 title = t.Dialog.Title.Render(title)
414 title = t.Dialog.Title.Render(title)
389
415
390 // Tool info.
416 // Tool info.
391− toolLine := p.renderKeyValue("Tool", p.permission.ToolName, contentWidth)
417+ toolLine := p.renderToolName(contentWidth)
392 pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
418 pathLine := p.renderKeyValue("Path", fsext.PrettyPath(p.permission.Path), contentWidth)
393
419
394 lines := []string{title, "", toolLine, pathLine}
420 lines := []string{title, "", toolLine, pathLine}
@@ -439,10 +465,33 @@ func (p *Permissions) renderKeyValue(key, value string, width int) string {
439 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
465 return lipgloss.JoinHorizontal(lipgloss.Left, keyStr, valueStr)
440}
466}
441
467
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+
442func (p *Permissions) renderContent(width int) string {
491func (p *Permissions) renderContent(width int) string {
443 switch p.permission.ToolName {
492 switch p.permission.ToolName {
444 case tools.BashToolName:
493 case tools.BashToolName:
445− return p.renderBashContent()
494+ return p.renderBashContent(width)
446 case tools.EditToolName:
495 case tools.EditToolName:
447 return p.renderEditContent(width)
496 return p.renderEditContent(width)
448 case tools.WriteToolName:
497 case tools.WriteToolName:
@@ -450,27 +499,27 @@ func (p *Permissions) renderContent(width int) string {
450 case tools.MultiEditToolName:
499 case tools.MultiEditToolName:
451 return p.renderMultiEditContent(width)
500 return p.renderMultiEditContent(width)
452 case tools.DownloadToolName:
501 case tools.DownloadToolName:
453− return p.renderDownloadContent()
502+ return p.renderDownloadContent(width)
454 case tools.FetchToolName:
503 case tools.FetchToolName:
455− return p.renderFetchContent()
504+ return p.renderFetchContent(width)
456 case tools.AgenticFetchToolName:
505 case tools.AgenticFetchToolName:
457− return p.renderAgenticFetchContent()
506+ return p.renderAgenticFetchContent(width)
458 case tools.ViewToolName:
507 case tools.ViewToolName:
459− return p.renderViewContent()
508+ return p.renderViewContent(width)
460 case tools.LSToolName:
509 case tools.LSToolName:
461− return p.renderLSContent()
510+ return p.renderLSContent(width)
462 default:
511 default:
463− return p.renderDefaultContent()
512+ return p.renderDefaultContent(width)
464 }
513 }
465}
514}
466
515
467−func (p *Permissions) renderBashContent() string {
516+func (p *Permissions) renderBashContent(width int) string {
468 params, ok := p.permission.Params.(tools.BashPermissionsParams)
517 params, ok := p.permission.Params.(tools.BashPermissionsParams)
469 if !ok {
518 if !ok {
470 return ""
519 return ""
471 }
520 }
472
521
473− return p.com.Styles.Dialog.ContentPanel.Render(params.Command)
522+ return p.renderContentPanel(params.Command, width)
474}
523}
475
524
476func (p *Permissions) renderEditContent(contentWidth int) string {
525func (p *Permissions) renderEditContent(contentWidth int) string {
@@ -529,7 +578,7 @@ func (p *Permissions) renderDiff(filePath, oldContent, newContent string, conten
529 return result
578 return result
530}
579}
531
580
532−func (p *Permissions) renderDownloadContent() string {
581+func (p *Permissions) renderDownloadContent(width int) string {
533 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
582 params, ok := p.permission.Params.(tools.DownloadPermissionsParams)
534 if !ok {
583 if !ok {
535 return ""
584 return ""
@@ -540,19 +589,19 @@ func (p *Permissions) renderDownloadContent() string {
540 content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
589 content += fmt.Sprintf("\nTimeout: %ds", params.Timeout)
541 }
590 }
542
591
543− return p.com.Styles.Dialog.ContentPanel.Render(content)
592+ return p.renderContentPanel(content, width)
544}
593}
545
594
546−func (p *Permissions) renderFetchContent() string {
595+func (p *Permissions) renderFetchContent(width int) string {
547 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
596 params, ok := p.permission.Params.(tools.FetchPermissionsParams)
548 if !ok {
597 if !ok {
549 return ""
598 return ""
550 }
599 }
551
600
552− return p.com.Styles.Dialog.ContentPanel.Render(params.URL)
601+ return p.renderContentPanel(params.URL, width)
553}
602}
554
603
555−func (p *Permissions) renderAgenticFetchContent() string {
604+func (p *Permissions) renderAgenticFetchContent(width int) string {
556 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
605 params, ok := p.permission.Params.(tools.AgenticFetchPermissionsParams)
557 if !ok {
606 if !ok {
558 return ""
607 return ""
@@ -565,10 +614,10 @@ func (p *Permissions) renderAgenticFetchContent() string {
565 content = fmt.Sprintf("Prompt: %s", params.Prompt)
614 content = fmt.Sprintf("Prompt: %s", params.Prompt)
566 }
615 }
567
616
568− return p.com.Styles.Dialog.ContentPanel.Render(content)
617+ return p.renderContentPanel(content, width)
569}
618}
570
619
571−func (p *Permissions) renderViewContent() string {
620+func (p *Permissions) renderViewContent(width int) string {
572 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
621 params, ok := p.permission.Params.(tools.ViewPermissionsParams)
573 if !ok {
622 if !ok {
574 return ""
623 return ""
@@ -582,10 +631,10 @@ func (p *Permissions) renderViewContent() string {
582 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
631 content += fmt.Sprintf("\nLines to read: %d", params.Limit)
583 }
632 }
584
633
585− return p.com.Styles.Dialog.ContentPanel.Render(content)
634+ return p.renderContentPanel(content, width)
586}
635}
587
636
588−func (p *Permissions) renderLSContent() string {
637+func (p *Permissions) renderLSContent(width int) string {
589 params, ok := p.permission.Params.(tools.LSPermissionsParams)
638 params, ok := p.permission.Params.(tools.LSPermissionsParams)
590 if !ok {
639 if !ok {
591 return ""
640 return ""
@@ -596,11 +645,16 @@ func (p *Permissions) renderLSContent() string {
596 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
645 content += fmt.Sprintf("\nIgnore patterns: %s", strings.Join(params.Ignore, ", "))
597 }
646 }
598
647
599− return p.com.Styles.Dialog.ContentPanel.Render(content)
648+ return p.renderContentPanel(content, width)
600}
649}
601
650
602−func (p *Permissions) renderDefaultContent() string {
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_") {
603− content := p.permission.Description
656+ content = p.permission.Description
657+ }
604
658
605 // Pretty-print JSON params if available.
659 // Pretty-print JSON params if available.
606 if p.permission.Params != nil {
660 if p.permission.Params != nil {
@@ -614,10 +668,15 @@ func (p *Permissions) renderDefaultContent() string {
614 var parsed any
668 var parsed any
615 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
669 if err := json.Unmarshal([]byte(paramStr), &parsed); err == nil {
616 if b, err := json.MarshalIndent(parsed, "", " "); err == nil {
670 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+ }
617 if content != "" {
676 if content != "" {
618 content += "\n\n"
677 content += "\n\n"
619 }
678 }
620− content += string(b)
679+ content += jsonContent
621 }
680 }
622 } else if paramStr != "" {
681 } else if paramStr != "" {
623 if content != "" {
682 if content != "" {
@@ -631,7 +690,13 @@ func (p *Permissions) renderDefaultContent() string {
631 return ""
690 return ""
632 }
691 }
633
692
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 {
634− return p.com.Styles.Dialog.ContentPanel.Render(strings.TrimSpace(content))
698+ panelStyle := p.com.Styles.Dialog.ContentPanel
699+ return panelStyle.Width(width).Render(content)
635}
700}
636
701
637func (p *Permissions) renderButtons(contentWidth int) string {
702func (p *Permissions) renderButtons(contentWidth int) string {
internal/ui/styles/styles.go+10
@@ -310,6 +310,11 @@ type Styles struct {
310 TodoCompletedIcon lipgloss.Style // Completed todo icon
310 TodoCompletedIcon lipgloss.Style // Completed todo icon
311 TodoInProgressIcon lipgloss.Style // In-progress todo icon
311 TodoInProgressIcon lipgloss.Style // In-progress todo icon
312 TodoPendingIcon lipgloss.Style // Pending todo icon
312 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
313 }
318 }
314
319
315 // Dialog styles
320 // Dialog styles
@@ -1130,6 +1135,11 @@ func DefaultStyles() Styles {
1130 s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
1135 s.Tool.TodoInProgressIcon = base.Foreground(greenDark)
1131 s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
1136 s.Tool.TodoPendingIcon = base.Foreground(fgMuted)
1132
1137
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+
1133 // Buttons
1143 // Buttons
1134 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1144 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
1135 s.ButtonBlur = s.Base.Background(bgSubtle)
1145 s.ButtonBlur = s.Base.Background(bgSubtle)