resourcepicker.go

  1package mcp
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  7
  8	"github.com/charmbracelet/bubbles/v2/help"
  9	"github.com/charmbracelet/bubbles/v2/key"
 10	"github.com/charmbracelet/bubbles/v2/list"
 11	tea "github.com/charmbracelet/bubbletea/v2"
 12	"github.com/charmbracelet/crush/internal/llm/agent"
 13	"github.com/charmbracelet/crush/internal/message"
 14	"github.com/charmbracelet/crush/internal/tui/components/core"
 15	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 16	"github.com/charmbracelet/crush/internal/tui/styles"
 17	"github.com/charmbracelet/crush/internal/tui/util"
 18	"github.com/charmbracelet/lipgloss/v2"
 19	"github.com/modelcontextprotocol/go-sdk/mcp"
 20)
 21
 22const (
 23	ResourcePickerID = "resourcepicker"
 24	listHeight       = 15
 25)
 26
 27type ResourcePickedMsg struct {
 28	Attachment message.Attachment
 29}
 30
 31type ResourcePicker interface {
 32	dialogs.DialogModel
 33}
 34
 35type resourceItem struct {
 36	clientName string
 37	resource   *mcp.Resource
 38}
 39
 40func (i resourceItem) Title() string {
 41	if i.resource.Title != "" {
 42		return i.resource.Title
 43	}
 44	return i.resource.Name
 45}
 46
 47func (i resourceItem) Description() string {
 48	desc := i.resource.Description
 49	if desc == "" {
 50		desc = i.resource.URI
 51	}
 52	return fmt.Sprintf("[%s] %s", i.clientName, desc)
 53}
 54
 55func (i resourceItem) FilterValue() string {
 56	return i.Title() + " " + i.Description()
 57}
 58
 59type model struct {
 60	wWidth  int
 61	wHeight int
 62	width   int
 63	list    list.Model
 64	keyMap  KeyMap
 65	help    help.Model
 66	loading bool
 67}
 68
 69func NewResourcePickerCmp() ResourcePicker {
 70	t := styles.CurrentTheme()
 71
 72	delegate := list.NewDefaultDelegate()
 73	delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(t.Accent)
 74	delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(t.FgMuted)
 75
 76	l := list.New([]list.Item{}, delegate, 0, listHeight)
 77	l.Title = "Select MCP Resource"
 78	l.Styles.Title = t.S().Title
 79	l.SetShowStatusBar(false)
 80	l.SetFilteringEnabled(true)
 81	l.DisableQuitKeybindings()
 82
 83	help := help.New()
 84	help.Styles = t.S().Help
 85
 86	return &model{
 87		list:    l,
 88		keyMap:  DefaultKeyMap(),
 89		help:    help,
 90		loading: true,
 91	}
 92}
 93
 94func (m *model) Init() tea.Cmd {
 95	return m.loadResources
 96}
 97
 98func (m *model) loadResources() tea.Msg {
 99	resources := agent.GetMCPResources()
100	items := make([]list.Item, 0, len(resources))
101
102	for key, resource := range resources {
103		parts := strings.SplitN(key, ":", 2)
104		if len(parts) != 2 {
105			continue
106		}
107		clientName := parts[0]
108		items = append(items, resourceItem{
109			clientName: clientName,
110			resource:   resource,
111		})
112	}
113
114	return resourcesLoadedMsg{items: items}
115}
116
117type resourcesLoadedMsg struct {
118	items []list.Item
119}
120
121func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122	switch msg := msg.(type) {
123	case tea.WindowSizeMsg:
124		m.wWidth = msg.Width
125		m.wHeight = msg.Height
126		m.width = min(80, m.wWidth-4)
127		h := min(listHeight+4, m.wHeight-4)
128		m.list.SetSize(m.width-4, h)
129		return m, nil
130
131	case resourcesLoadedMsg:
132		m.loading = false
133		cmd := m.list.SetItems(msg.items)
134		if len(msg.items) == 0 {
135			return m, tea.Batch(
136				cmd,
137				util.ReportWarn("No MCP resources available"),
138				util.CmdHandler(dialogs.CloseDialogMsg{}),
139			)
140		}
141		return m, cmd
142
143	case tea.KeyPressMsg:
144		if key.Matches(msg, m.keyMap.Close) {
145			return m, util.CmdHandler(dialogs.CloseDialogMsg{})
146		}
147		if key.Matches(msg, m.keyMap.Select) {
148			if item, ok := m.list.SelectedItem().(resourceItem); ok {
149				return m, tea.Sequence(
150					util.CmdHandler(dialogs.CloseDialogMsg{}),
151					m.fetchResource(item),
152				)
153			}
154		}
155	}
156
157	var cmd tea.Cmd
158	m.list, cmd = m.list.Update(msg)
159	return m, cmd
160}
161
162func (m *model) fetchResource(item resourceItem) tea.Cmd {
163	return func() tea.Msg {
164		ctx := context.Background()
165		content, err := agent.GetMCPResourceContent(ctx, item.clientName, item.resource.URI)
166		if err != nil {
167			return util.ReportError(fmt.Errorf("failed to fetch resource: %w", err))
168		}
169
170		var textContent strings.Builder
171		for _, c := range content.Contents {
172			if c.Text != "" {
173				textContent.WriteString(c.Text)
174			} else if len(c.Blob) > 0 {
175				textContent.WriteString(string(c.Blob))
176			}
177		}
178
179		fileName := item.resource.Name
180		if item.resource.Title != "" {
181			fileName = item.resource.Title
182		}
183
184		mimeType := item.resource.MIMEType
185		if mimeType == "" {
186			mimeType = "text/plain"
187		}
188
189		attachment := message.Attachment{
190			FileName: fileName,
191			FilePath: fileName,
192			MimeType: mimeType,
193			Content:  []byte(textContent.String()),
194		}
195
196		return ResourcePickedMsg{Attachment: attachment}
197	}
198}
199
200func (m *model) View() string {
201	t := styles.CurrentTheme()
202
203	if m.loading {
204		return m.style().Render(
205			lipgloss.JoinVertical(
206				lipgloss.Left,
207				t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Loading MCP Resources...", m.width-4)),
208			),
209		)
210	}
211
212	content := lipgloss.JoinVertical(
213		lipgloss.Left,
214		m.list.View(),
215		t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
216	)
217
218	return m.style().Render(content)
219}
220
221func (m *model) style() lipgloss.Style {
222	t := styles.CurrentTheme()
223	return t.S().Base.
224		Width(m.width).
225		Border(lipgloss.RoundedBorder()).
226		BorderForeground(t.BorderFocus)
227}
228
229func (m *model) Position() (int, int) {
230	x := (m.wWidth - m.width) / 2
231	y := (m.wHeight - listHeight - 6) / 2
232	return y, x
233}
234
235func (m *model) ID() dialogs.DialogID {
236	return ResourcePickerID
237}