feat: paste long content as an attachment (#1634)

Carlos Alexandro Becker and Kujtim Hoxha created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Kujtim Hoxha <kujtimii.h@gmail.com>

Change summary

internal/agent/agent.go                           |   7 
internal/agent/coordinator.go                     |   9 
internal/message/attachment.go                    |   5 
internal/message/content.go                       |  37 +++
internal/tui/components/chat/editor/editor.go     | 170 +++++++++++-----
internal/tui/components/chat/messages/messages.go |  31 ++
internal/tui/page/chat/chat.go                    |   7 
internal/tui/styles/icons.go                      |   3 
8 files changed, 202 insertions(+), 67 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -200,7 +200,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
 	var currentAssistant *message.Message
 	var shouldSummarize bool
 	result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
-		Prompt:           call.Prompt,
+		Prompt:           message.PromptWithTextAttachments(call.Prompt, call.Attachments),
 		Files:            files,
 		Messages:         history,
 		ProviderOptions:  call.ProviderOptions,
@@ -649,11 +649,11 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
 }
 
 func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
+	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
 	var attachmentParts []message.ContentPart
 	for _, attachment := range call.Attachments {
 		attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
 	}
-	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
 	parts = append(parts, attachmentParts...)
 	msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
 		Role:  message.User,
@@ -690,6 +690,9 @@ If not, please feel free to ignore. Again do not mention this message to the use
 
 	var files []fantasy.FilePart
 	for _, attachment := range attachments {
+		if attachment.IsText() {
+			continue
+		}
 		files = append(files, fantasy.FilePart{
 			Filename:  attachment.FileName,
 			Data:      attachment.Content,

internal/agent/coordinator.go 🔗

@@ -123,7 +123,14 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	}
 
 	if !model.CatwalkCfg.SupportsImages && attachments != nil {
-		attachments = nil
+		// filter out image attachments
+		filteredAttachments := make([]message.Attachment, 0, len(attachments))
+		for _, att := range attachments {
+			if att.IsText() {
+				filteredAttachments = append(filteredAttachments, att)
+			}
+		}
+		attachments = filteredAttachments
 	}
 
 	providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)

internal/message/attachment.go 🔗

@@ -1,8 +1,13 @@
 package message
 
+import "strings"
+
 type Attachment struct {
 	FilePath string
 	FileName string
 	MimeType string
 	Content  []byte
 }
+
+func (a Attachment) IsText() bool  { return strings.HasPrefix(a.MimeType, "text/") }
+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }

internal/message/content.go 🔗

@@ -3,6 +3,7 @@ package message
 import (
 	"encoding/base64"
 	"errors"
+	"fmt"
 	"slices"
 	"strings"
 	"time"
@@ -435,16 +436,52 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
 	m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
 }
 
+func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
+	addedAttachments := false
+	for _, content := range attachments {
+		if !content.IsText() {
+			continue
+		}
+		if !addedAttachments {
+			prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
+			addedAttachments = true
+		}
+		tag := `<file>\n`
+		if content.FilePath != "" {
+			tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
+		}
+		prompt += tag
+		prompt += "\n" + string(content.Content) + "\n</file>\n"
+	}
+	return prompt
+}
+
 func (m *Message) ToAIMessage() []fantasy.Message {
 	var messages []fantasy.Message
 	switch m.Role {
 	case User:
 		var parts []fantasy.MessagePart
 		text := strings.TrimSpace(m.Content().Text)
+		var textAttachments []Attachment
+		for _, content := range m.BinaryContent() {
+			if !strings.HasPrefix(content.MIMEType, "text/") {
+				continue
+			}
+			textAttachments = append(textAttachments, Attachment{
+				FilePath: content.Path,
+				MimeType: content.MIMEType,
+				Content:  content.Data,
+			})
+		}
+		text = PromptWithTextAttachments(text, textAttachments)
 		if text != "" {
 			parts = append(parts, fantasy.TextPart{Text: text})
 		}
 		for _, content := range m.BinaryContent() {
+			// skip text attachements
+			if strings.HasPrefix(content.MIMEType, "text/") {
+				continue
+			}
 			parts = append(parts, fantasy.FilePart{
 				Filename:  content.Path,
 				Data:      content.Data,

internal/tui/components/chat/editor/editor.go 🔗

@@ -2,6 +2,7 @@ package editor
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -29,6 +30,7 @@ import (
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
+	"github.com/charmbracelet/x/ansi"
 )
 
 type Editor interface {
@@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
 	),
 }
 
-const (
-	maxAttachments = 5
-	maxFileResults = 25
-)
+const maxFileResults = 25
 
 type OpenEditorMsg struct {
 	Text string
@@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd {
 		return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
 	}
 
-	m.textarea.Reset()
 	attachments := m.attachments
 
-	m.attachments = nil
 	if value == "" {
 		return nil
 	}
 
+	m.textarea.Reset()
+	m.attachments = nil
 	// Change the placeholder when sending a new message.
 	m.randomizePlaceholders()
 
@@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		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))
-		}
 		m.attachments = append(m.attachments, msg.Attachment)
 		return m, nil
 	case completions.CompletionsOpenedMsg:
@@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				m.currentQuery = ""
 				m.completionsStartIndex = 0
 			}
+			content, err := os.ReadFile(item.Path)
+			if err != nil {
+				// if it fails, let the LLM handle it later.
+				return m, nil
+			}
+			m.attachments = append(m.attachments, message.Attachment{
+				FilePath: item.Path,
+				FileName: filepath.Base(item.Path),
+				MimeType: mimeOf(content),
+				Content:  content,
+			})
 		}
 
 	case commands.OpenExternalEditorMsg:
@@ -217,39 +224,30 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.PasteMsg:
-		path := strings.ReplaceAll(msg.Content, "\\ ", " ")
-		// try to get an image
-		path, err := filepath.Abs(strings.TrimSpace(path))
-		if err != nil {
+		content, path, err := pasteToFile(msg)
+		if errors.Is(err, errNotAFile) {
 			m.textarea, cmd = m.textarea.Update(msg)
 			return m, cmd
 		}
-		isAllowedType := false
-		for _, ext := range filepicker.AllowedTypes {
-			if strings.HasSuffix(path, ext) {
-				isAllowedType = true
-				break
-			}
-		}
-		if !isAllowedType {
-			m.textarea, cmd = m.textarea.Update(msg)
-			return m, cmd
+		if err != nil {
+			return m, util.ReportError(err)
 		}
-		tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
-		if tooBig {
-			m.textarea, cmd = m.textarea.Update(msg)
-			return m, cmd
+
+		if len(content) > maxAttachmentSize {
+			return m, util.ReportWarn("File is too big (>5mb)")
 		}
 
-		content, err := os.ReadFile(path)
-		if err != nil {
-			m.textarea, cmd = m.textarea.Update(msg)
-			return m, cmd
+		mimeType := mimeOf(content)
+		attachment := message.Attachment{
+			FilePath: path,
+			FileName: filepath.Base(path),
+			MimeType: mimeType,
+			Content:  content,
 		}
-		mimeBufferSize := min(512, len(content))
-		mimeType := http.DetectContentType(content[:mimeBufferSize])
-		fileName := filepath.Base(path)
-		attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
+		if !attachment.IsText() && !attachment.IsImage() {
+			return m, util.ReportWarn("Invalid file content type: " + mimeType)
+		}
+		m.textarea.InsertString(attachment.FileName)
 		return m, util.CmdHandler(filepicker.FilePickedMsg{
 			Attachment: attachment,
 		})
@@ -427,18 +425,17 @@ func (m *editorCmp) View() string {
 		m.textarea.Placeholder = "Yolo mode!"
 	}
 	if len(m.attachments) == 0 {
-		content := t.S().Base.Padding(1).Render(
+		return t.S().Base.Padding(1).Render(
 			m.textarea.View(),
 		)
-		return content
 	}
-	content := t.S().Base.Padding(0, 1, 1, 1).Render(
-		lipgloss.JoinVertical(lipgloss.Top,
+	return t.S().Base.Padding(0, 1, 1, 1).Render(
+		lipgloss.JoinVertical(
+			lipgloss.Top,
 			m.attachmentsContent(),
 			m.textarea.View(),
 		),
 	)
-	return content
 }
 
 func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -456,24 +453,45 @@ func (m *editorCmp) GetSize() (int, int) {
 func (m *editorCmp) attachmentsContent() string {
 	var styledAttachments []string
 	t := styles.CurrentTheme()
-	attachmentStyles := t.S().Base.
-		MarginLeft(1).
+	attachmentStyle := t.S().Base.
+		Padding(0, 1).
+		MarginRight(1).
 		Background(t.FgMuted).
-		Foreground(t.FgBase)
+		Foreground(t.FgBase).
+		Render
+	iconStyle := t.S().Base.
+		Foreground(t.BgSubtle).
+		Background(t.Green).
+		Padding(0, 1).
+		Bold(true).
+		Render
+	rmStyle := t.S().Base.
+		Padding(0, 1).
+		Bold(true).
+		Background(t.Red).
+		Foreground(t.FgBase).
+		Render
 	for i, attachment := range m.attachments {
-		var filename string
-		if len(attachment.FileName) > 10 {
-			filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
-		} else {
-			filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
+		filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
+		icon := styles.ImageIcon
+		if attachment.IsText() {
+			icon = styles.TextIcon
 		}
 		if m.deleteMode {
-			filename = fmt.Sprintf("%d%s", i, filename)
+			styledAttachments = append(
+				styledAttachments,
+				rmStyle(fmt.Sprintf("%d", i)),
+				attachmentStyle(filename),
+			)
+			continue
 		}
-		styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
+		styledAttachments = append(
+			styledAttachments,
+			iconStyle(icon),
+			attachmentStyle(filename),
+		)
 	}
-	content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
-	return content
+	return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
 }
 
 func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
@@ -597,3 +615,51 @@ func New(app *app.App) Editor {
 
 	return e
 }
+
+var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
+
+var errNotAFile = errors.New("not a file")
+
+func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
+	content, path, err := filepathToFile(msg.Content)
+	if err == nil {
+		return content, path, err
+	}
+
+	if strings.Count(msg.Content, "\n") > 2 {
+		return contentToFile([]byte(msg.Content))
+	}
+
+	return nil, "", errNotAFile
+}
+
+func contentToFile(content []byte) ([]byte, string, error) {
+	f, err := os.CreateTemp("", "paste_*.txt")
+	if err != nil {
+		return nil, "", err
+	}
+	if _, err := f.Write(content); err != nil {
+		return nil, "", err
+	}
+	if err := f.Close(); err != nil {
+		return nil, "", err
+	}
+	return content, f.Name(), nil
+}
+
+func filepathToFile(name string) ([]byte, string, error) {
+	path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
+	if err != nil {
+		return nil, "", err
+	}
+	content, err := os.ReadFile(path)
+	if err != nil {
+		return nil, "", err
+	}
+	return content, path, nil
+}
+
+func mimeOf(content []byte) string {
+	mimeBufferSize := min(512, len(content))
+	return http.DetectContentType(content[:mimeBufferSize])
+}

internal/tui/components/chat/messages/messages.go 🔗

@@ -227,19 +227,32 @@ func (m *messageCmp) renderUserMessage() string {
 		m.toMarkdown(m.message.Content().String()),
 	}
 
-	attachmentStyles := t.S().Text.
-		MarginLeft(1).
-		Background(t.BgSubtle)
+	attachmentStyle := t.S().Base.
+		Padding(0, 1).
+		MarginRight(1).
+		Background(t.FgMuted).
+		Foreground(t.FgBase).
+		Render
+	iconStyle := t.S().Base.
+		Foreground(t.BgSubtle).
+		Background(t.Green).
+		Padding(0, 1).
+		Bold(true).
+		Render
 
 	attachments := make([]string, len(m.message.BinaryContent()))
 	for i, attachment := range m.message.BinaryContent() {
 		const maxFilenameWidth = 10
-		filename := filepath.Base(attachment.Path)
-		attachments[i] = attachmentStyles.Render(fmt.Sprintf(
-			" %s %s ",
-			styles.DocumentIcon,
-			ansi.Truncate(filename, maxFilenameWidth, "..."),
-		))
+		filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
+		icon := styles.ImageIcon
+		if strings.HasPrefix(attachment.MIMEType, "text/") {
+			icon = styles.TextIcon
+		}
+		attachments[i] = lipgloss.JoinHorizontal(
+			lipgloss.Left,
+			iconStyle(icon),
+			attachmentStyle(filename),
+		)
 	}
 
 	if len(attachments) > 0 {

internal/tui/page/chat/chat.go 🔗

@@ -613,8 +613,11 @@ func (p *chatPage) View() string {
 				pillsArea = pillsRow
 			}
 
-			style := t.S().Base.MarginTop(1).PaddingLeft(3)
-			pillsArea = style.Render(pillsArea)
+			pillsArea = t.S().Base.
+				MaxWidth(p.width).
+				MarginTop(1).
+				PaddingLeft(3).
+				Render(pillsArea)
 		}
 
 		if p.compact {

internal/tui/styles/icons.go 🔗

@@ -10,7 +10,8 @@ const (
 	ArrowRightIcon    string = "→"
 	CenterSpinnerIcon string = "⋯"
 	LoadingIcon       string = "⟳"
-	DocumentIcon      string = "🖼"
+	ImageIcon         string = "■"
+	TextIcon          string = "☰"
 	ModelIcon         string = "◇"
 
 	// Tool call icons