From f44035ea80fe213874063f9af6aa5a6a807b3e2b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Mon, 9 Jun 2025 12:19:06 +0200 Subject: [PATCH] feat: add attachments to messages --- internal/tui/components/chat/editor/editor.go | 62 ++++++++----------- internal/tui/components/core/status/status.go | 2 +- .../dialogs/filepicker/filepicker.go | 46 +++++++++++++- internal/tui/page/chat/chat.go | 12 +++- internal/tui/tui.go | 3 +- 5 files changed, 83 insertions(+), 42 deletions(-) diff --git a/internal/tui/components/chat/editor/editor.go b/internal/tui/components/chat/editor/editor.go index d8ae8d71d6dfe4038c73fe6e0bd1b686c0c071e5..0eb63fb9e836de1ff5c45bf7e91a4dcc12309e08 100644 --- a/internal/tui/components/chat/editor/editor.go +++ b/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, diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index 796d2edf634a08d1b3fbf42d67c0ff818de59b75..ef5ebef108e9252aefa353fddefdde3538a28497 100644 --- a/internal/tui/components/core/status/status.go +++ b/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: diff --git a/internal/tui/components/dialogs/filepicker/filepicker.go b/internal/tui/components/dialogs/filepicker/filepicker.go index 6b67e309e66c4455835c2315062c6c4f9081a169..916209b6f6371b7c5961f9fbc507f9c680f9e59b 100644 --- a/internal/tui/components/dialogs/filepicker/filepicker.go +++ b/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 +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index 05a12a9a23c57b96a115558a820ab729269bb67f..4a501f658e9f5f2b0a1367b11d34c6304c983a48 100644 --- a/internal/tui/page/chat/chat.go +++ b/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 == "" { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 87f140838f224368fbabe59af47d1933b15312be..c14d93bd392c8dd44496efc1a42a8e0d905bb7f6 100644 --- a/internal/tui/tui.go +++ b/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