feat: add attachments to messages

Kujtim Hoxha created

Change summary

internal/tui/components/chat/editor/editor.go            | 62 ++++-----
internal/tui/components/core/status/status.go            |  2 
internal/tui/components/dialogs/filepicker/filepicker.go | 46 +++++++
internal/tui/page/chat/chat.go                           | 12 +
internal/tui/tui.go                                      |  3 
5 files changed, 83 insertions(+), 42 deletions(-)

Detailed changes

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

@@ -18,6 +18,7 @@ import (
 	"github.com/charmbracelet/crush/internal/session"
 	"github.com/charmbracelet/crush/internal/tui/components/chat"
 	"github.com/charmbracelet/crush/internal/tui/components/completions"
+	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/tui/layout"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
@@ -141,13 +142,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.session = msg
 		}
 		return m, nil
-	// case dialog.AttachmentAddedMsg:
-	// 	if len(m.attachments) >= maxAttachments {
-	// 		logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
-	// 		return m, cmd
-	// 	}
-	// 	m.attachments = append(m.attachments, msg.Attachment)
-	// 	return m, nil
+	case filepicker.FilePickedMsg:
+		if len(m.attachments) >= maxAttachments {
+			logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
+			return m, cmd
+		}
+		m.attachments = append(m.attachments, msg.Attachment)
+		return m, nil
 	case completions.CompletionsClosedMsg:
 		m.isCompletionsOpen = false
 		m.currentQuery = ""
@@ -351,7 +352,24 @@ func (m *editorCmp) startCompletions() tea.Msg {
 	}
 }
 
-func CreateTextArea(existing *textarea.Model) textarea.Model {
+// Blur implements Container.
+func (c *editorCmp) Blur() tea.Cmd {
+	c.textarea.Blur()
+	return nil
+}
+
+// Focus implements Container.
+func (c *editorCmp) Focus() tea.Cmd {
+	logging.Info("Focusing editor textarea")
+	return c.textarea.Focus()
+}
+
+// IsFocused implements Container.
+func (c *editorCmp) IsFocused() bool {
+	return c.textarea.Focused()
+}
+
+func NewEditorCmp(app *app.App) util.Model {
 	t := styles.CurrentTheme()
 	ta := textarea.New()
 	ta.SetStyles(t.S().TextArea)
@@ -369,36 +387,8 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
 	ta.CharLimit = -1
 	ta.Placeholder = "Tell me more about this project..."
 	ta.SetVirtualCursor(false)
-
-	if existing != nil {
-		ta.SetValue(existing.Value())
-		ta.SetWidth(existing.Width())
-		ta.SetHeight(existing.Height())
-	}
-
 	ta.Focus()
-	return ta
-}
 
-// Blur implements Container.
-func (c *editorCmp) Blur() tea.Cmd {
-	c.textarea.Blur()
-	return nil
-}
-
-// Focus implements Container.
-func (c *editorCmp) Focus() tea.Cmd {
-	logging.Info("Focusing editor textarea")
-	return c.textarea.Focus()
-}
-
-// IsFocused implements Container.
-func (c *editorCmp) IsFocused() bool {
-	return c.textarea.Focused()
-}
-
-func NewEditorCmp(app *app.App) util.Model {
-	ta := CreateTextArea(nil)
 	return &editorCmp{
 		app:      app,
 		textarea: ta,

internal/tui/components/core/status/status.go 🔗

@@ -88,7 +88,7 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 func (m statusCmp) View() tea.View {
 	t := styles.CurrentTheme()
-	status := t.S().Base.Padding(0, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
+	status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
 	if m.info.Msg != "" {
 		switch m.info.Type {
 		case util.InfoTypeError:

internal/tui/components/dialogs/filepicker/filepicker.go 🔗

@@ -1,13 +1,18 @@
 package filepicker
 
 import (
+	"fmt"
+	"net/http"
 	"os"
+	"path/filepath"
 	"strings"
 
 	"github.com/charmbracelet/bubbles/v2/filepicker"
 	"github.com/charmbracelet/bubbles/v2/help"
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/crush/internal/logging"
+	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/dialogs"
 	"github.com/charmbracelet/crush/internal/tui/components/image"
@@ -23,7 +28,7 @@ const (
 )
 
 type FilePickedMsg struct {
-	FilePath string
+	Attachment message.Attachment
 }
 
 type FilePicker interface {
@@ -111,7 +116,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		// Get the path of the selected file.
 		return m, tea.Sequence(
 			util.CmdHandler(dialogs.CloseDialogMsg{}),
-			util.CmdHandler(FilePickedMsg{FilePath: path}),
+			func() tea.Msg {
+				isFileLarge, err := ValidateFileSize(path, maxAttachmentSize)
+				if err != nil {
+					logging.ErrorPersist("unable to read the image")
+					return nil
+				}
+				if isFileLarge {
+					logging.ErrorPersist("file too large, max 5MB")
+					return nil
+				}
+
+				content, err := os.ReadFile(path)
+				if err != nil {
+					logging.ErrorPersist("Unable read selected file")
+					return nil
+				}
+
+				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}
+				return FilePickedMsg{
+					Attachment: attachment,
+				}
+			},
 		)
 	}
 	m.image, cmd = m.image.Update(msg)
@@ -185,3 +214,16 @@ func (m *model) Position() (int, int) {
 	col -= m.width / 2
 	return row, col
 }
+
+func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
+	fileInfo, err := os.Stat(filePath)
+	if err != nil {
+		return false, fmt.Errorf("error getting file info: %w", err)
+	}
+
+	if fileInfo.Size() > sizeLimit {
+		return true, nil
+	}
+
+	return false, nil
+}

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

@@ -6,6 +6,8 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/app"
+	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/llm/models"
 	"github.com/charmbracelet/crush/internal/logging"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/session"
@@ -87,7 +89,15 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			)
 
 		case key.Matches(msg, p.keyMap.FilePicker):
-			return p, util.CmdHandler(OpenFilePickerMsg{})
+			cfg := config.Get()
+			agentCfg := cfg.Agents[config.AgentCoder]
+			selectedModelID := agentCfg.Model
+			model := models.SupportedModels[selectedModelID]
+			if model.SupportsAttachments {
+				return p, util.CmdHandler(OpenFilePickerMsg{})
+			} else {
+				return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
+			}
 		case key.Matches(msg, p.keyMap.Tab):
 			logging.Info("Tab key pressed, toggling chat focus")
 			if p.session.ID == "" {

internal/tui/tui.go 🔗

@@ -85,7 +85,6 @@ func (a appModel) Init() tea.Cmd {
 
 // Update handles incoming messages and updates the application state.
 func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	logging.Info("TUI Update", "msg", msg)
 	var cmds []tea.Cmd
 	var cmd tea.Cmd
 
@@ -248,7 +247,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 // handleWindowResize processes window resize events and updates all components.
 func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
 	var cmds []tea.Cmd
-	msg.Height -= 1 // Make space for the status bar
+	msg.Height -= 2 // Make space for the status bar
 	a.width, a.height = msg.Width, msg.Height
 
 	// Update status bar