feat: allow to send the prompt if its empty but has text attachments (#1806)

Carlos Alexandro Becker and Copilot created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

Change summary

internal/agent/agent.go                           |  2 
internal/message/attachment.go                    | 12 ++
internal/tui/components/chat/editor/editor.go     | 72 +++++++++-------
internal/tui/components/chat/messages/messages.go |  8 +
4 files changed, 56 insertions(+), 38 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -134,7 +134,7 @@ func NewSessionAgent(
 }
 
 func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
-	if call.Prompt == "" {
+	if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
 		return nil, ErrEmptyPrompt
 	}
 	if call.SessionID == "" {

internal/message/attachment.go 🔗

@@ -1,6 +1,9 @@
 package message
 
-import "strings"
+import (
+	"slices"
+	"strings"
+)
 
 type Attachment struct {
 	FilePath string
@@ -11,3 +14,10 @@ type Attachment struct {
 
 func (a Attachment) IsText() bool  { return strings.HasPrefix(a.MimeType, "text/") }
 func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
+
+// ContainsTextAttachment returns true if any of the attachments is a text attachments.
+func ContainsTextAttachment(attachments []Attachment) bool {
+	return slices.ContainsFunc(attachments, func(a Attachment) bool {
+		return a.IsText()
+	})
+}

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

@@ -1,13 +1,14 @@
 package editor
 
 import (
-	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
 	"os"
 	"path/filepath"
+	"regexp"
 	"slices"
+	"strconv"
 	"strings"
 	"unicode"
 
@@ -146,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd {
 
 	attachments := m.attachments
 
-	if value == "" {
+	if value == "" && !message.ContainsTextAttachment(attachments) {
 		return nil
 	}
 
@@ -233,13 +234,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.PasteMsg:
-		content, path, err := pasteToFile(msg)
-		if errors.Is(err, errNotAFile) {
-			m.textarea, cmd = m.textarea.Update(msg)
-			return m, cmd
+		// If pasted text has more than 2 newlines, treat it as a file attachment.
+		if strings.Count(msg.Content, "\n") > 2 {
+			content := []byte(msg.Content)
+			if len(content) > maxAttachmentSize {
+				return m, util.ReportWarn("Paste is too big (>5mb)")
+			}
+			name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
+			mimeType := mimeOf(content)
+			attachment := message.Attachment{
+				FileName: name,
+				FilePath: name,
+				MimeType: mimeType,
+				Content:  content,
+			}
+			return m, util.CmdHandler(filepicker.FilePickedMsg{
+				Attachment: attachment,
+			})
 		}
+
+		// Try to parse as a file path.
+		content, path, err := filepathToFile(msg.Content)
 		if err != nil {
-			return m, util.ReportError(err)
+			// Not a file path, just update the textarea normally.
+			m.textarea, cmd = m.textarea.Update(msg)
+			return m, cmd
 		}
 
 		if len(content) > maxAttachmentSize {
@@ -256,7 +275,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		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,
 		})
@@ -627,33 +645,21 @@ func New(app *app.App) Editor {
 
 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
-}
+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
 
-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
+func (m *editorCmp) pasteIdx() int {
+	result := 0
+	for _, at := range m.attachments {
+		found := pasteRE.FindStringSubmatch(at.FileName)
+		if len(found) == 0 {
+			continue
+		}
+		idx, err := strconv.Atoi(found[1])
+		if err == nil {
+			result = max(result, idx)
+		}
 	}
-	return content, f.Name(), nil
+	return result + 1
 }
 
 func filepathToFile(name string) ([]byte, string, error) {

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

@@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string {
 // message content and any attached files with appropriate icons.
 func (m *messageCmp) renderUserMessage() string {
 	t := styles.CurrentTheme()
-	parts := []string{
-		m.toMarkdown(m.message.Content().String()),
+	var parts []string
+
+	if s := m.message.Content().String(); s != "" {
+		parts = append(parts, m.toMarkdown(s))
 	}
 
 	attachmentStyle := t.S().Base.
@@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string {
 	}
 
 	if len(attachments) > 0 {
-		parts = append(parts, "", strings.Join(attachments, ""))
+		parts = append(parts, strings.Join(attachments, ""))
 	}
 
 	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)