on main help sign in
feat: paste long content as an attachment (#1634)

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

commitb3f73213fd7ee20329632d3b9d47f792ba00482c authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 19 Dec 2025, 16:39 (1 year ago) parent5d419e8
8 files changed+202−67
internal/agent/agent.go+5−2 internal/agent/coordinator.go+8−1 internal/message/attachment.go+5 internal/message/content.go+37 internal/tui/components/chat/editor/editor.go+118−52 internal/tui/components/chat/messages/messages.go+22−9 internal/tui/page/chat/chat.go+5−2 internal/tui/styles/icons.go+2−1
internal/agent/agent.go+5−2
@@ -200,7 +200,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
200200 var currentAssistant *message.Message
201201 var shouldSummarize bool
202202 result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
203− Prompt: call.Prompt,
203+ Prompt: message.PromptWithTextAttachments(call.Prompt, call.Attachments),
204204 Files: files,
205205 Messages: history,
206206 ProviderOptions: call.ProviderOptions,
@@ -649,11 +649,11 @@ func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
649649}
650650
651651func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
652+ parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
652653 var attachmentParts []message.ContentPart
653654 for _, attachment := range call.Attachments {
654655 attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
655656 }
656− parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
657657 parts = append(parts, attachmentParts...)
658658 msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
659659 Role: message.User,
@@ -690,6 +690,9 @@ If not, please feel free to ignore. Again do not mention this message to the use
690690
691691 var files []fantasy.FilePart
692692 for _, attachment := range attachments {
693+ if attachment.IsText() {
694+ continue
695+ }
693696 files = append(files, fantasy.FilePart{
694697 Filename: attachment.FileName,
695698 Data: attachment.Content,
internal/agent/coordinator.go+8−1
@@ -123,7 +123,14 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
123123 }
124124
125125 if !model.CatwalkCfg.SupportsImages && attachments != nil {
126− attachments = nil
126+ // filter out image attachments
127+ filteredAttachments := make([]message.Attachment, 0, len(attachments))
128+ for _, att := range attachments {
129+ if att.IsText() {
130+ filteredAttachments = append(filteredAttachments, att)
131+ }
132+ }
133+ attachments = filteredAttachments
127134 }
128135
129136 providerCfg, ok := c.cfg.Providers.Get(model.ModelCfg.Provider)
internal/message/attachment.go+5
@@ -1,8 +1,13 @@
11package message
22
3+import "strings"
4+
35type Attachment struct {
46 FilePath string
57 FileName string
68 MimeType string
79 Content []byte
810}
11+
12+func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
13+func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
internal/message/content.go+37
@@ -3,6 +3,7 @@ package message
33import (
44 "encoding/base64"
55 "errors"
6+ "fmt"
67 "slices"
78 "strings"
89 "time"
@@ -435,16 +436,52 @@ func (m *Message) AddBinary(mimeType string, data []byte) {
435436 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
436437}
437438
439+func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
440+ addedAttachments := false
441+ for _, content := range attachments {
442+ if !content.IsText() {
443+ continue
444+ }
445+ if !addedAttachments {
446+ prompt += "\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n"
447+ addedAttachments = true
448+ }
449+ tag := `<file>\n`
450+ if content.FilePath != "" {
451+ tag = fmt.Sprintf("<file path='%s'>\n", content.FilePath)
452+ }
453+ prompt += tag
454+ prompt += "\n" + string(content.Content) + "\n</file>\n"
455+ }
456+ return prompt
457+}
458+
438459func (m *Message) ToAIMessage() []fantasy.Message {
439460 var messages []fantasy.Message
440461 switch m.Role {
441462 case User:
442463 var parts []fantasy.MessagePart
443464 text := strings.TrimSpace(m.Content().Text)
465+ var textAttachments []Attachment
466+ for _, content := range m.BinaryContent() {
467+ if !strings.HasPrefix(content.MIMEType, "text/") {
468+ continue
469+ }
470+ textAttachments = append(textAttachments, Attachment{
471+ FilePath: content.Path,
472+ MimeType: content.MIMEType,
473+ Content: content.Data,
474+ })
475+ }
476+ text = PromptWithTextAttachments(text, textAttachments)
444477 if text != "" {
445478 parts = append(parts, fantasy.TextPart{Text: text})
446479 }
447480 for _, content := range m.BinaryContent() {
481+ // skip text attachements
482+ if strings.HasPrefix(content.MIMEType, "text/") {
483+ continue
484+ }
448485 parts = append(parts, fantasy.FilePart{
449486 Filename: content.Path,
450487 Data: content.Data,
internal/tui/components/chat/editor/editor.go+118−52
@@ -2,6 +2,7 @@ package editor
22
33import (
44 "context"
5+ "errors"
56 "fmt"
67 "math/rand"
78 "net/http"
@@ -29,6 +30,7 @@ import (
2930 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
3031 "github.com/charmbracelet/crush/internal/tui/styles"
3132 "github.com/charmbracelet/crush/internal/tui/util"
33+ "github.com/charmbracelet/x/ansi"
3234)
3335
3436type Editor interface {
@@ -84,10 +86,7 @@ var DeleteKeyMaps = DeleteAttachmentKeyMaps{
8486 ),
8587}
8688
87−const (
88− maxAttachments = 5
89− maxFileResults = 25
90−)
89+const maxFileResults = 25
9190
9291type OpenEditorMsg struct {
9392 Text string
@@ -145,14 +144,14 @@ func (m *editorCmp) send() tea.Cmd {
145144 return util.CmdHandler(dialogs.OpenDialogMsg{Model: quit.NewQuitDialog()})
146145 }
147146
148− m.textarea.Reset()
149147 attachments := m.attachments
150148
151− m.attachments = nil
152149 if value == "" {
153150 return nil
154151 }
155152
153+ m.textarea.Reset()
154+ m.attachments = nil
156155 // Change the placeholder when sending a new message.
157156 m.randomizePlaceholders()
158157
@@ -176,9 +175,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
176175 case tea.WindowSizeMsg:
177176 return m, m.repositionCompletions
178177 case filepicker.FilePickedMsg:
179− if len(m.attachments) >= maxAttachments {
180− return m, util.ReportError(fmt.Errorf("cannot add more than %d images", maxAttachments))
181− }
182178 m.attachments = append(m.attachments, msg.Attachment)
183179 return m, nil
184180 case completions.CompletionsOpenedMsg:
@@ -206,6 +202,17 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
206202 m.currentQuery = ""
207203 m.completionsStartIndex = 0
208204 }
205+ content, err := os.ReadFile(item.Path)
206+ if err != nil {
207+ // if it fails, let the LLM handle it later.
208+ return m, nil
209+ }
210+ m.attachments = append(m.attachments, message.Attachment{
211+ FilePath: item.Path,
212+ FileName: filepath.Base(item.Path),
213+ MimeType: mimeOf(content),
214+ Content: content,
215+ })
209216 }
210217
211218 case commands.OpenExternalEditorMsg:
@@ -217,39 +224,30 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
217224 m.textarea.SetValue(msg.Text)
218225 m.textarea.MoveToEnd()
219226 case tea.PasteMsg:
220− path := strings.ReplaceAll(msg.Content, "\\ ", " ")
221− // try to get an image
222− path, err := filepath.Abs(strings.TrimSpace(path))
223− if err != nil {
227+ content, path, err := pasteToFile(msg)
228+ if errors.Is(err, errNotAFile) {
224229 m.textarea, cmd = m.textarea.Update(msg)
225230 return m, cmd
226231 }
227− isAllowedType := false
228− for _, ext := range filepicker.AllowedTypes {
229− if strings.HasSuffix(path, ext) {
230− isAllowedType = true
231− break
232− }
233− }
234− if !isAllowedType {
235− m.textarea, cmd = m.textarea.Update(msg)
236− return m, cmd
232+ if err != nil {
233+ return m, util.ReportError(err)
237234 }
238− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
239− if tooBig {
240− m.textarea, cmd = m.textarea.Update(msg)
241− return m, cmd
235+
236+ if len(content) > maxAttachmentSize {
237+ return m, util.ReportWarn("File is too big (>5mb)")
242238 }
243239
244− content, err := os.ReadFile(path)
245− if err != nil {
246− m.textarea, cmd = m.textarea.Update(msg)
247− return m, cmd
240+ mimeType := mimeOf(content)
241+ attachment := message.Attachment{
242+ FilePath: path,
243+ FileName: filepath.Base(path),
244+ MimeType: mimeType,
245+ Content: content,
248246 }
249− mimeBufferSize := min(512, len(content))
250− mimeType := http.DetectContentType(content[:mimeBufferSize])
251− fileName := filepath.Base(path)
252− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
247+ if !attachment.IsText() && !attachment.IsImage() {
248+ return m, util.ReportWarn("Invalid file content type: " + mimeType)
249+ }
250+ m.textarea.InsertString(attachment.FileName)
253251 return m, util.CmdHandler(filepicker.FilePickedMsg{
254252 Attachment: attachment,
255253 })
@@ -427,18 +425,17 @@ func (m *editorCmp) View() string {
427425 m.textarea.Placeholder = "Yolo mode!"
428426 }
429427 if len(m.attachments) == 0 {
430− content := t.S().Base.Padding(1).Render(
428+ return t.S().Base.Padding(1).Render(
431429 m.textarea.View(),
432430 )
433− return content
434431 }
435− content := t.S().Base.Padding(0, 1, 1, 1).Render(
436− lipgloss.JoinVertical(lipgloss.Top,
432+ return t.S().Base.Padding(0, 1, 1, 1).Render(
433+ lipgloss.JoinVertical(
434+ lipgloss.Top,
437435 m.attachmentsContent(),
438436 m.textarea.View(),
439437 ),
440438 )
441− return content
442439}
443440
444441func (m *editorCmp) SetSize(width, height int) tea.Cmd {
@@ -456,24 +453,45 @@ func (m *editorCmp) GetSize() (int, int) {
456453func (m *editorCmp) attachmentsContent() string {
457454 var styledAttachments []string
458455 t := styles.CurrentTheme()
459− attachmentStyles := t.S().Base.
460− MarginLeft(1).
456+ attachmentStyle := t.S().Base.
457+ Padding(0, 1).
458+ MarginRight(1).
461459 Background(t.FgMuted).
462− Foreground(t.FgBase)
460+ Foreground(t.FgBase).
461+ Render
462+ iconStyle := t.S().Base.
463+ Foreground(t.BgSubtle).
464+ Background(t.Green).
465+ Padding(0, 1).
466+ Bold(true).
467+ Render
468+ rmStyle := t.S().Base.
469+ Padding(0, 1).
470+ Bold(true).
471+ Background(t.Red).
472+ Foreground(t.FgBase).
473+ Render
463474 for i, attachment := range m.attachments {
464− var filename string
465− if len(attachment.FileName) > 10 {
466− filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
467− } else {
468− filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
475+ filename := ansi.Truncate(filepath.Base(attachment.FileName), 10, "...")
476+ icon := styles.ImageIcon
477+ if attachment.IsText() {
478+ icon = styles.TextIcon
469479 }
470480 if m.deleteMode {
471− filename = fmt.Sprintf("%d%s", i, filename)
481+ styledAttachments = append(
482+ styledAttachments,
483+ rmStyle(fmt.Sprintf("%d", i)),
484+ attachmentStyle(filename),
485+ )
486+ continue
472487 }
473− styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
488+ styledAttachments = append(
489+ styledAttachments,
490+ iconStyle(icon),
491+ attachmentStyle(filename),
492+ )
474493 }
475− content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
476− return content
494+ return lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
477495}
478496
479497func (m *editorCmp) SetPosition(x, y int) tea.Cmd {
@@ -597,3 +615,51 @@ func New(app *app.App) Editor {
597615
598616 return e
599617}
618+
619+var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
620+
621+var errNotAFile = errors.New("not a file")
622+
623+func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
624+ content, path, err := filepathToFile(msg.Content)
625+ if err == nil {
626+ return content, path, err
627+ }
628+
629+ if strings.Count(msg.Content, "\n") > 2 {
630+ return contentToFile([]byte(msg.Content))
631+ }
632+
633+ return nil, "", errNotAFile
634+}
635+
636+func contentToFile(content []byte) ([]byte, string, error) {
637+ f, err := os.CreateTemp("", "paste_*.txt")
638+ if err != nil {
639+ return nil, "", err
640+ }
641+ if _, err := f.Write(content); err != nil {
642+ return nil, "", err
643+ }
644+ if err := f.Close(); err != nil {
645+ return nil, "", err
646+ }
647+ return content, f.Name(), nil
648+}
649+
650+func filepathToFile(name string) ([]byte, string, error) {
651+ path, err := filepath.Abs(strings.TrimSpace(strings.ReplaceAll(name, "\\", "")))
652+ if err != nil {
653+ return nil, "", err
654+ }
655+ content, err := os.ReadFile(path)
656+ if err != nil {
657+ return nil, "", err
658+ }
659+ return content, path, nil
660+}
661+
662+func mimeOf(content []byte) string {
663+ mimeBufferSize := min(512, len(content))
664+ return http.DetectContentType(content[:mimeBufferSize])
665+}
internal/tui/components/chat/messages/messages.go+22−9
@@ -227,19 +227,32 @@ func (m *messageCmp) renderUserMessage() string {
227227 m.toMarkdown(m.message.Content().String()),
228228 }
229229
230− attachmentStyles := t.S().Text.
231− MarginLeft(1).
232− Background(t.BgSubtle)
230+ attachmentStyle := t.S().Base.
231+ Padding(0, 1).
232+ MarginRight(1).
233+ Background(t.FgMuted).
234+ Foreground(t.FgBase).
235+ Render
236+ iconStyle := t.S().Base.
237+ Foreground(t.BgSubtle).
238+ Background(t.Green).
239+ Padding(0, 1).
240+ Bold(true).
241+ Render
233242
234243 attachments := make([]string, len(m.message.BinaryContent()))
235244 for i, attachment := range m.message.BinaryContent() {
236245 const maxFilenameWidth = 10
237− filename := filepath.Base(attachment.Path)
238− attachments[i] = attachmentStyles.Render(fmt.Sprintf(
239− " %s %s ",
240− styles.DocumentIcon,
241− ansi.Truncate(filename, maxFilenameWidth, "..."),
242− ))
246+ filename := ansi.Truncate(filepath.Base(attachment.Path), 10, "...")
247+ icon := styles.ImageIcon
248+ if strings.HasPrefix(attachment.MIMEType, "text/") {
249+ icon = styles.TextIcon
250+ }
251+ attachments[i] = lipgloss.JoinHorizontal(
252+ lipgloss.Left,
253+ iconStyle(icon),
254+ attachmentStyle(filename),
255+ )
243256 }
244257
245258 if len(attachments) > 0 {
internal/tui/page/chat/chat.go+5−2
@@ -613,8 +613,11 @@ func (p *chatPage) View() string {
613613 pillsArea = pillsRow
614614 }
615615
616− style := t.S().Base.MarginTop(1).PaddingLeft(3)
617− pillsArea = style.Render(pillsArea)
616+ pillsArea = t.S().Base.
617+ MaxWidth(p.width).
618+ MarginTop(1).
619+ PaddingLeft(3).
620+ Render(pillsArea)
618621 }
619622
620623 if p.compact {
internal/tui/styles/icons.go+2−1
@@ -10,7 +10,8 @@ const (
1010 ArrowRightIcon string = "→"
1111 CenterSpinnerIcon string = "⋯"
1212 LoadingIcon string = "⟳"
13− DocumentIcon string = "🖼"
13+ ImageIcon string = "■"
14+ TextIcon string = "☰"
1415 ModelIcon string = "◇"
1516
1617 // Tool call icons