on main help sign in
feat: allow to send the prompt if its empty but has text attachments (#1806)

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

commit617f9e28f634089fed552cc28a987851f23c48e0 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateFri 9 Jan 2026, 14:12 (9 months ago) parent23c6e43
4 files changed+56−38
internal/agent/agent.go+1−1 internal/message/attachment.go+11−1 internal/tui/components/chat/editor/editor.go+39−33 internal/tui/components/chat/messages/messages.go+5−3
internal/agent/agent.go+1−1
@@ -134,7 +134,7 @@ func NewSessionAgent(
134134}
135135
136136func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
137− if call.Prompt == "" {
137+ if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
138138 return nil, ErrEmptyPrompt
139139 }
140140 if call.SessionID == "" {
internal/message/attachment.go+11−1
@@ -1,6 +1,9 @@
11package message
22
3−import "strings"
3+import (
4+ "slices"
5+ "strings"
6+)
47
58type Attachment struct {
69 FilePath string
@@ -11,3 +14,10 @@ type Attachment struct {
1114
1215func (a Attachment) IsText() bool { return strings.HasPrefix(a.MimeType, "text/") }
1316func (a Attachment) IsImage() bool { return strings.HasPrefix(a.MimeType, "image/") }
17+
18+// ContainsTextAttachment returns true if any of the attachments is a text attachments.
19+func ContainsTextAttachment(attachments []Attachment) bool {
20+ return slices.ContainsFunc(attachments, func(a Attachment) bool {
21+ return a.IsText()
22+ })
23+}
internal/tui/components/chat/editor/editor.go+39−33
@@ -1,13 +1,14 @@
11package editor
22
33import (
4− "errors"
54 "fmt"
65 "math/rand"
76 "net/http"
87 "os"
98 "path/filepath"
9+ "regexp"
1010 "slices"
11+ "strconv"
1112 "strings"
1213 "unicode"
1314
@@ -146,7 +147,7 @@ func (m *editorCmp) send() tea.Cmd {
146147
147148 attachments := m.attachments
148149
149− if value == "" {
150+ if value == "" && !message.ContainsTextAttachment(attachments) {
150151 return nil
151152 }
152153
@@ -233,13 +234,31 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
233234 m.textarea.SetValue(msg.Text)
234235 m.textarea.MoveToEnd()
235236 case tea.PasteMsg:
236− content, path, err := pasteToFile(msg)
237− if errors.Is(err, errNotAFile) {
238− m.textarea, cmd = m.textarea.Update(msg)
239− return m, cmd
237+ // If pasted text has more than 2 newlines, treat it as a file attachment.
238+ if strings.Count(msg.Content, "\n") > 2 {
239+ content := []byte(msg.Content)
240+ if len(content) > maxAttachmentSize {
241+ return m, util.ReportWarn("Paste is too big (>5mb)")
242+ }
243+ name := fmt.Sprintf("paste_%d.txt", m.pasteIdx())
244+ mimeType := mimeOf(content)
245+ attachment := message.Attachment{
246+ FileName: name,
247+ FilePath: name,
248+ MimeType: mimeType,
249+ Content: content,
250+ }
251+ return m, util.CmdHandler(filepicker.FilePickedMsg{
252+ Attachment: attachment,
253+ })
240254 }
255+
256+ // Try to parse as a file path.
257+ content, path, err := filepathToFile(msg.Content)
241258 if err != nil {
242− return m, util.ReportError(err)
259+ // Not a file path, just update the textarea normally.
260+ m.textarea, cmd = m.textarea.Update(msg)
261+ return m, cmd
243262 }
244263
245264 if len(content) > maxAttachmentSize {
@@ -256,7 +275,6 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
256275 if !attachment.IsText() && !attachment.IsImage() {
257276 return m, util.ReportWarn("Invalid file content type: " + mimeType)
258277 }
259− m.textarea.InsertString(attachment.FileName)
260278 return m, util.CmdHandler(filepicker.FilePickedMsg{
261279 Attachment: attachment,
262280 })
@@ -627,33 +645,21 @@ func New(app *app.App) Editor {
627645
628646var maxAttachmentSize = 5 * 1024 * 1024 // 5MB
629647
630−var errNotAFile = errors.New("not a file")
631−
632−func pasteToFile(msg tea.PasteMsg) ([]byte, string, error) {
633− content, path, err := filepathToFile(msg.Content)
634− if err == nil {
635− return content, path, err
636− }
637−
638− if strings.Count(msg.Content, "\n") > 2 {
639− return contentToFile([]byte(msg.Content))
640− }
641−
642− return nil, "", errNotAFile
643−}
648+var pasteRE = regexp.MustCompile(`paste_(\d+).txt`)
644649
645−func contentToFile(content []byte) ([]byte, string, error) {
646− f, err := os.CreateTemp("", "paste_*.txt")
647− if err != nil {
648− return nil, "", err
649− }
650− if _, err := f.Write(content); err != nil {
651− return nil, "", err
652− }
653− if err := f.Close(); err != nil {
654− return nil, "", err
650+func (m *editorCmp) pasteIdx() int {
651+ result := 0
652+ for _, at := range m.attachments {
653+ found := pasteRE.FindStringSubmatch(at.FileName)
654+ if len(found) == 0 {
655+ continue
656+ }
657+ idx, err := strconv.Atoi(found[1])
658+ if err == nil {
659+ result = max(result, idx)
660+ }
655661 }
656− return content, f.Name(), nil
662+ return result + 1
657663}
658664
659665func filepathToFile(name string) ([]byte, string, error) {
internal/tui/components/chat/messages/messages.go+5−3
@@ -223,8 +223,10 @@ func (m *messageCmp) renderAssistantMessage() string {
223223// message content and any attached files with appropriate icons.
224224func (m *messageCmp) renderUserMessage() string {
225225 t := styles.CurrentTheme()
226− parts := []string{
227− m.toMarkdown(m.message.Content().String()),
226+ var parts []string
227+
228+ if s := m.message.Content().String(); s != "" {
229+ parts = append(parts, m.toMarkdown(s))
228230 }
229231
230232 attachmentStyle := t.S().Base.
@@ -256,7 +258,7 @@ func (m *messageCmp) renderUserMessage() string {
256258 }
257259
258260 if len(attachments) > 0 {
259− parts = append(parts, "", strings.Join(attachments, ""))
261+ parts = append(parts, strings.Join(attachments, ""))
260262 }
261263
262264 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)