diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 759a9274f2f4cc8c306ac0cc042de89cd1a25097..7c7ac4c6c1f3d320fe3e3dd865f8e7b56c73010d 100644 --- a/internal/agent/agent.go +++ b/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 == "" { diff --git a/internal/message/attachment.go b/internal/message/attachment.go index 0e3b70a8766c74d37399c1ba8c38fe19e74f871d..b04863f39cc5b266662395344d5227cfa12f4188 100644 --- a/internal/message/attachment.go +++ b/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() + }) +} diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index 8f9b326b9f941bb99cfbaad992830c3173ea41c4..01badb98d37eb848ccf5962e01793ecaa3fc0f59 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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) { diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 1359823edb7a783cd23b600e1ddae3870f2a2107..b4db149946fe0a1f67c957eeb04da2966e1f5f28 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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...)