Detailed changes
@@ -346,9 +346,6 @@ func (a *agent) err(err error) AgentEvent {
}
func (a *agent) Run(ctx context.Context, sessionID string, content string, attachments ...message.Attachment) (<-chan AgentEvent, error) {
- if !a.Model().SupportsImages && attachments != nil {
- attachments = nil
- }
events := make(chan AgentEvent, 1)
if a.IsSessionBusy(sessionID) {
existing, ok := a.promptQueue.Get(sessionID)
@@ -371,7 +368,15 @@ func (a *agent) Run(ctx context.Context, sessionID string, content string, attac
})
var attachmentParts []message.ContentPart
for _, attachment := range attachments {
- attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
+ if !a.Model().SupportsImages && strings.HasPrefix(attachment.MimeType, "image/") {
+ slog.Warn("Model does not support images, skipping attachment", "mimeType", attachment.MimeType, "fileName", attachment.FileName)
+ continue
+ }
+ attachmentParts = append(attachmentParts, message.BinaryContent{
+ Path: attachment.FilePath,
+ MIMEType: attachment.MimeType,
+ Data: attachment.Content,
+ })
}
result := a.processGeneration(genCtx, sessionID, content, attachmentParts)
if result.Error != nil {
@@ -562,3 +562,30 @@ func GetMCPPromptContent(ctx context.Context, clientName, promptName string, arg
Arguments: args,
})
}
+
+// GetMCPResources returns all available MCP resources.
+func GetMCPResources() map[string]*mcp.Resource {
+ return maps.Collect(mcpResources.Seq2())
+}
+
+// GetMCPResource returns a specific MCP resource by name.
+func GetMCPResource(name string) (*mcp.Resource, bool) {
+ return mcpResources.Get(name)
+}
+
+// GetMCPResourcesByClient returns all resources for a specific MCP client.
+func GetMCPResourcesByClient(clientName string) ([]*mcp.Resource, bool) {
+ return mcpClient2Resources.Get(clientName)
+}
+
+// GetMCPResourceContent retrieves the content of an MCP resource.
+func GetMCPResourceContent(ctx context.Context, clientName, uri string) (*mcp.ReadResourceResult, error) {
+ c, err := getOrRenewClient(ctx, clientName)
+ if err != nil {
+ return nil, err
+ }
+
+ return c.ReadResource(ctx, &mcp.ReadResourceParams{
+ URI: uri,
+ })
+}
@@ -126,9 +126,16 @@ func (a *anthropicClient) convertMessages(messages []message.Message) (anthropic
var contentBlocks []anthropic.ContentBlockParamUnion
contentBlocks = append(contentBlocks, content)
for _, binaryContent := range msg.BinaryContent() {
- base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
- imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
- contentBlocks = append(contentBlocks, imageBlock)
+ if strings.HasPrefix(binaryContent.MIMEType, "image/") {
+ base64Image := binaryContent.String(catwalk.InferenceProviderAnthropic)
+ imageBlock := anthropic.NewImageBlockBase64(binaryContent.MIMEType, base64Image)
+ contentBlocks = append(contentBlocks, imageBlock)
+ continue
+ }
+ blk := anthropic.NewDocumentBlock(anthropic.PlainTextSourceParam{
+ Data: string(binaryContent.Data),
+ })
+ contentBlocks = append(contentBlocks, blk)
}
anthropicMessages = append(anthropicMessages, anthropic.NewUserMessage(contentBlocks...))
@@ -26,6 +26,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/mcp"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
"github.com/charmbracelet/crush/internal/tui/styles"
"github.com/charmbracelet/crush/internal/tui/util"
@@ -179,10 +180,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.repositionCompletions
case filepicker.FilePickedMsg:
if len(m.attachments) >= maxAttachments {
- return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
+ // TODO: check if this still needed
+ return m, util.ReportError(fmt.Errorf("cannot add more than %d attachments", maxAttachments))
}
m.attachments = append(m.attachments, msg.Attachment)
return m, nil
+ case mcp.ResourcePickedMsg:
+ m.attachments = append(m.attachments, msg.Attachment)
case completions.CompletionsOpenedMsg:
m.isCompletionsOpen = true
case completions.CompletionsClosedMsg:
@@ -458,16 +462,21 @@ func (m *editorCmp) attachmentsContent() string {
Background(t.FgMuted).
Foreground(t.FgBase)
for i, attachment := range m.attachments {
- var filename string
+ icon := styles.DocumentIcon
+ if strings.HasPrefix(attachment.MimeType, "image/") {
+ icon = styles.ImageIcon
+ }
+
+ var item string
if len(attachment.FileName) > 10 {
- filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
+ item = fmt.Sprintf(" %s %s...", icon, attachment.FileName[0:7])
} else {
- filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
+ item = fmt.Sprintf(" %s %s", icon, attachment.FileName)
}
if m.deleteMode {
- filename = fmt.Sprintf("%d%s", i, filename)
+ item = fmt.Sprintf("%d%s", i, item)
}
- styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
+ styledAttachments = append(styledAttachments, attachmentStyles.Render(item))
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
return content
@@ -79,6 +79,7 @@ type (
SwitchModelMsg struct{}
QuitMsg struct{}
OpenFilePickerMsg struct{}
+ OpenResourcePickerMsg struct{}
ToggleHelpMsg struct{}
ToggleCompactModeMsg struct{}
ToggleThinkingMsg struct{}
@@ -0,0 +1,33 @@
+package mcp
+
+import (
+ "github.com/charmbracelet/bubbles/v2/key"
+)
+
+type KeyMap struct {
+ Close key.Binding
+ Select key.Binding
+}
+
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Close: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "close"),
+ ),
+ Select: key.NewBinding(
+ key.WithKeys("enter"),
+ key.WithHelp("enter", "select"),
+ ),
+ }
+}
+
+func (k KeyMap) ShortHelp() []key.Binding {
+ return []key.Binding{k.Select, k.Close}
+}
+
+func (k KeyMap) FullHelp() [][]key.Binding {
+ return [][]key.Binding{
+ {k.Select, k.Close},
+ }
+}
@@ -0,0 +1,237 @@
+package mcp
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/v2/help"
+ "github.com/charmbracelet/bubbles/v2/key"
+ "github.com/charmbracelet/bubbles/v2/list"
+ tea "github.com/charmbracelet/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/llm/agent"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/tui/components/core"
+ "github.com/charmbracelet/crush/internal/tui/components/dialogs"
+ "github.com/charmbracelet/crush/internal/tui/styles"
+ "github.com/charmbracelet/crush/internal/tui/util"
+ "github.com/charmbracelet/lipgloss/v2"
+ "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+const (
+ ResourcePickerID = "resourcepicker"
+ listHeight = 15
+)
+
+type ResourcePickedMsg struct {
+ Attachment message.Attachment
+}
+
+type ResourcePicker interface {
+ dialogs.DialogModel
+}
+
+type resourceItem struct {
+ clientName string
+ resource *mcp.Resource
+}
+
+func (i resourceItem) Title() string {
+ if i.resource.Title != "" {
+ return i.resource.Title
+ }
+ return i.resource.Name
+}
+
+func (i resourceItem) Description() string {
+ desc := i.resource.Description
+ if desc == "" {
+ desc = i.resource.URI
+ }
+ return fmt.Sprintf("[%s] %s", i.clientName, desc)
+}
+
+func (i resourceItem) FilterValue() string {
+ return i.Title() + " " + i.Description()
+}
+
+type model struct {
+ wWidth int
+ wHeight int
+ width int
+ list list.Model
+ keyMap KeyMap
+ help help.Model
+ loading bool
+}
+
+func NewResourcePickerCmp() ResourcePicker {
+ t := styles.CurrentTheme()
+
+ delegate := list.NewDefaultDelegate()
+ delegate.Styles.SelectedTitle = delegate.Styles.SelectedTitle.Foreground(t.Accent)
+ delegate.Styles.SelectedDesc = delegate.Styles.SelectedDesc.Foreground(t.FgMuted)
+
+ l := list.New([]list.Item{}, delegate, 0, listHeight)
+ l.Title = "Select MCP Resource"
+ l.Styles.Title = t.S().Title
+ l.SetShowStatusBar(false)
+ l.SetFilteringEnabled(true)
+ l.DisableQuitKeybindings()
+
+ help := help.New()
+ help.Styles = t.S().Help
+
+ return &model{
+ list: l,
+ keyMap: DefaultKeyMap(),
+ help: help,
+ loading: true,
+ }
+}
+
+func (m *model) Init() tea.Cmd {
+ return m.loadResources
+}
+
+func (m *model) loadResources() tea.Msg {
+ resources := agent.GetMCPResources()
+ items := make([]list.Item, 0, len(resources))
+
+ for key, resource := range resources {
+ parts := strings.SplitN(key, ":", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ clientName := parts[0]
+ items = append(items, resourceItem{
+ clientName: clientName,
+ resource: resource,
+ })
+ }
+
+ return resourcesLoadedMsg{items: items}
+}
+
+type resourcesLoadedMsg struct {
+ items []list.Item
+}
+
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ m.wWidth = msg.Width
+ m.wHeight = msg.Height
+ m.width = min(80, m.wWidth-4)
+ h := min(listHeight+4, m.wHeight-4)
+ m.list.SetSize(m.width-4, h)
+ return m, nil
+
+ case resourcesLoadedMsg:
+ m.loading = false
+ cmd := m.list.SetItems(msg.items)
+ if len(msg.items) == 0 {
+ return m, tea.Batch(
+ cmd,
+ util.ReportWarn("No MCP resources available"),
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ )
+ }
+ return m, cmd
+
+ case tea.KeyPressMsg:
+ if key.Matches(msg, m.keyMap.Close) {
+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
+ }
+ if key.Matches(msg, m.keyMap.Select) {
+ if item, ok := m.list.SelectedItem().(resourceItem); ok {
+ return m, tea.Sequence(
+ util.CmdHandler(dialogs.CloseDialogMsg{}),
+ m.fetchResource(item),
+ )
+ }
+ }
+ }
+
+ var cmd tea.Cmd
+ m.list, cmd = m.list.Update(msg)
+ return m, cmd
+}
+
+func (m *model) fetchResource(item resourceItem) tea.Cmd {
+ return func() tea.Msg {
+ ctx := context.Background()
+ content, err := agent.GetMCPResourceContent(ctx, item.clientName, item.resource.URI)
+ if err != nil {
+ return util.ReportError(fmt.Errorf("failed to fetch resource: %w", err))
+ }
+
+ var textContent strings.Builder
+ for _, c := range content.Contents {
+ if c.Text != "" {
+ textContent.WriteString(c.Text)
+ } else if len(c.Blob) > 0 {
+ textContent.WriteString(string(c.Blob))
+ }
+ }
+
+ fileName := item.resource.Name
+ if item.resource.Title != "" {
+ fileName = item.resource.Title
+ }
+
+ mimeType := item.resource.MIMEType
+ if mimeType == "" {
+ mimeType = "text/plain"
+ }
+
+ attachment := message.Attachment{
+ FileName: fileName,
+ FilePath: fileName,
+ MimeType: mimeType,
+ Content: []byte(textContent.String()),
+ }
+
+ return ResourcePickedMsg{Attachment: attachment}
+ }
+}
+
+func (m *model) View() string {
+ t := styles.CurrentTheme()
+
+ if m.loading {
+ return m.style().Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Loading MCP Resources...", m.width-4)),
+ ),
+ )
+ }
+
+ content := lipgloss.JoinVertical(
+ lipgloss.Left,
+ m.list.View(),
+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
+ )
+
+ return m.style().Render(content)
+}
+
+func (m *model) style() lipgloss.Style {
+ t := styles.CurrentTheme()
+ return t.S().Base.
+ Width(m.width).
+ Border(lipgloss.RoundedBorder()).
+ BorderForeground(t.BorderFocus)
+}
+
+func (m *model) Position() (int, int) {
+ x := (m.wWidth - m.width) / 2
+ y := (m.wHeight - listHeight - 6) / 2
+ return y, x
+}
+
+func (m *model) ID() dialogs.DialogID {
+ return ResourcePickerID
+}
@@ -3,6 +3,7 @@ package chat
import (
"context"
"fmt"
+ "log/slog"
"time"
"github.com/charmbracelet/bubbles/v2/help"
@@ -30,6 +31,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/dialogs"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/mcp"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/reasoning"
"github.com/charmbracelet/crush/internal/tui/page"
@@ -286,6 +288,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, cmd)
return p, tea.Batch(cmds...)
case filepicker.FilePickedMsg,
+ mcp.ResourcePickedMsg,
completions.CompletionsClosedMsg,
completions.SelectCompletionMsg:
u, cmd := p.editor.Update(msg)
@@ -378,6 +381,9 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
}
+ case key.Matches(msg, p.keyMap.AddMCPResource):
+ slog.Warn("AQUI")
+ return p, util.CmdHandler(commands.OpenResourcePickerMsg{})
case key.Matches(msg, p.keyMap.Tab):
if p.session.ID == "" {
u, cmd := p.splash.Update(msg)
@@ -761,6 +767,7 @@ func (p *chatPage) Bindings() []key.Binding {
bindings := []key.Binding{
p.keyMap.NewSession,
p.keyMap.AddAttachment,
+ p.keyMap.AddMCPResource,
}
if p.app.CoderAgent != nil && p.app.CoderAgent.IsBusy() {
cancelBinding := p.keyMap.Cancel
@@ -1021,6 +1028,10 @@ func (p *chatPage) Help() help.KeyMap {
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "add image"),
),
+ key.NewBinding(
+ key.WithKeys("ctrl+m"),
+ key.WithHelp("ctrl+m", "add mcp resource"),
+ ),
key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "add file"),
@@ -5,11 +5,12 @@ import (
)
type KeyMap struct {
- NewSession key.Binding
- AddAttachment key.Binding
- Cancel key.Binding
- Tab key.Binding
- Details key.Binding
+ NewSession key.Binding
+ AddAttachment key.Binding
+ AddMCPResource key.Binding
+ Cancel key.Binding
+ Tab key.Binding
+ Details key.Binding
}
func DefaultKeyMap() KeyMap {
@@ -22,6 +23,10 @@ func DefaultKeyMap() KeyMap {
key.WithKeys("ctrl+f"),
key.WithHelp("ctrl+f", "add attachment"),
),
+ AddMCPResource: key.NewBinding(
+ key.WithKeys("ctrl+r"),
+ key.WithHelp("ctrl+r", "add mcp resource"),
+ ),
Cancel: key.NewBinding(
key.WithKeys("esc", "alt+esc"),
key.WithHelp("esc", "cancel"),
@@ -8,7 +8,8 @@ const (
HintIcon string = "∵"
SpinnerIcon string = "..."
LoadingIcon string = "⟳"
- DocumentIcon string = "🖼"
+ DocumentIcon string = "📄 "
+ ImageIcon string = "🖼"
ModelIcon string = "◇"
// Tool call icons
@@ -25,6 +25,7 @@ import (
"github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/mcp"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -254,6 +255,14 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, util.CmdHandler(dialogs.OpenDialogMsg{
Model: filepicker.NewFilePickerCmp(a.app.Config().WorkingDir()),
})
+ // Resource Picker
+ case commands.OpenResourcePickerMsg:
+ if a.dialog.ActiveDialogID() == mcp.ResourcePickerID {
+ return a, util.CmdHandler(dialogs.CloseDialogMsg{})
+ }
+ return a, util.CmdHandler(dialogs.OpenDialogMsg{
+ Model: mcp.NewResourcePickerCmp(),
+ })
// Permissions
case pubsub.Event[permission.PermissionNotification]:
item, ok := a.pages[a.currentPage]