resourcepicker.go

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