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.Secondary)
 68	delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(t.FgMuted)
 69
 70	l := list.New([]list.Item{}, delegate, 0, listHeight)
 71	l.SetShowStatusBar(false)
 72	l.SetShowTitle(false)
 73	l.SetFilteringEnabled(true)
 74	l.DisableQuitKeybindings()
 75
 76	help := help.New()
 77	help.Styles = t.S().Help
 78
 79	return &model{
 80		list:    l,
 81		keyMap:  DefaultKeyMap(),
 82		help:    help,
 83		loading: true,
 84	}
 85}
 86
 87func (m *model) Init() tea.Cmd {
 88	return m.loadResources
 89}
 90
 91func (m *model) loadResources() tea.Msg {
 92	resources := agent.GetMCPResources()
 93	items := make([]list.Item, 0, len(resources))
 94
 95	for key, resource := range resources {
 96		parts := strings.SplitN(key, ":", 2)
 97		if len(parts) != 2 {
 98			continue
 99		}
100		clientName := parts[0]
101		items = append(items, resourceItem{
102			clientName: clientName,
103			resource:   resource,
104		})
105	}
106
107	return resourcesLoadedMsg{items: items}
108}
109
110type resourcesLoadedMsg struct {
111	items []list.Item
112}
113
114func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115	switch msg := msg.(type) {
116	case tea.WindowSizeMsg:
117		m.wWidth = msg.Width
118		m.wHeight = msg.Height
119		m.width = min(80, m.wWidth-4)
120		h := min(listHeight+4, m.wHeight-4)
121		m.list.SetSize(m.width-4, h)
122		return m, nil
123
124	case resourcesLoadedMsg:
125		m.loading = false
126		cmd := m.list.SetItems(msg.items)
127		if len(msg.items) == 0 {
128			return m, tea.Batch(
129				cmd,
130				util.ReportWarn("No MCP resources available"),
131				util.CmdHandler(dialogs.CloseDialogMsg{}),
132			)
133		}
134		return m, cmd
135
136	case tea.KeyPressMsg:
137		if key.Matches(msg, m.keyMap.Close) {
138			return m, util.CmdHandler(dialogs.CloseDialogMsg{})
139		}
140		if key.Matches(msg, m.keyMap.Select) {
141			if item, ok := m.list.SelectedItem().(resourceItem); ok {
142				return m, tea.Sequence(
143					util.CmdHandler(dialogs.CloseDialogMsg{}),
144					m.fetchResource(item),
145				)
146			}
147		}
148	}
149
150	var cmd tea.Cmd
151	m.list, cmd = m.list.Update(msg)
152	return m, cmd
153}
154
155func (m *model) fetchResource(item resourceItem) tea.Cmd {
156	return func() tea.Msg {
157		ctx := context.Background()
158		content, err := agent.GetMCPResourceContent(ctx, item.clientName, item.resource.URI)
159		if err != nil {
160			return util.ReportError(fmt.Errorf("failed to fetch resource: %w", err))
161		}
162
163		var textContent strings.Builder
164		for _, c := range content.Contents {
165			if c.Text != "" {
166				textContent.WriteString(c.Text)
167			} else if len(c.Blob) > 0 {
168				textContent.WriteString(string(c.Blob))
169			}
170		}
171
172		fileName := item.resource.Name
173		if item.resource.Title != "" {
174			fileName = item.resource.Title
175		}
176
177		mimeType := item.resource.MIMEType
178		if mimeType == "" {
179			mimeType = "text/plain"
180		}
181
182		attachment := message.Attachment{
183			FileName: fileName,
184			FilePath: fileName,
185			MimeType: mimeType,
186			Content:  []byte(textContent.String()),
187		}
188
189		return ResourcePickedMsg{Attachment: attachment}
190	}
191}
192
193func (m *model) View() string {
194	t := styles.CurrentTheme()
195
196	if m.loading {
197		return m.style().Render(
198			lipgloss.JoinVertical(
199				lipgloss.Left,
200				t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Loading MCP Resources...", m.width-4)),
201			),
202		)
203	}
204
205	content := lipgloss.JoinVertical(
206		lipgloss.Left,
207		t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Add MCP Resource", m.width-4)),
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}