on main help sign in
feat: add attachments to messages
f44035eKujtim Hoxha
internal/tui/components/chat/editor/editor.go+26−36
@@ -18,6 +18,7 @@ import (
18 "github.com/charmbracelet/crush/internal/session"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
19 "github.com/charmbracelet/crush/internal/tui/components/chat"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
20 "github.com/charmbracelet/crush/internal/tui/components/completions"
21+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
21 "github.com/charmbracelet/crush/internal/tui/layout"
22 "github.com/charmbracelet/crush/internal/tui/layout"
22 "github.com/charmbracelet/crush/internal/tui/styles"
23 "github.com/charmbracelet/crush/internal/tui/styles"
23 "github.com/charmbracelet/crush/internal/tui/util"
24 "github.com/charmbracelet/crush/internal/tui/util"
@@ -141,13 +142,13 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141 m.session = msg
142 m.session = msg
142 }
143 }
143 return m, nil
144 return m, nil
144− // case dialog.AttachmentAddedMsg:
145+ case filepicker.FilePickedMsg:
145− // if len(m.attachments) >= maxAttachments {
146+ if len(m.attachments) >= maxAttachments {
146− // logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
147+ logging.ErrorPersist(fmt.Sprintf("cannot add more than %d images", maxAttachments))
147− // return m, cmd
148+ return m, cmd
148− // }
149+ }
149− // m.attachments = append(m.attachments, msg.Attachment)
150+ m.attachments = append(m.attachments, msg.Attachment)
150− // return m, nil
151+ return m, nil
151 case completions.CompletionsClosedMsg:
152 case completions.CompletionsClosedMsg:
152 m.isCompletionsOpen = false
153 m.isCompletionsOpen = false
153 m.currentQuery = ""
154 m.currentQuery = ""
@@ -351,7 +352,24 @@ func (m *editorCmp) startCompletions() tea.Msg {
351 }
352 }
352}
353}
353
354
354−func CreateTextArea(existing *textarea.Model) textarea.Model {
355+// Blur implements Container.
356+func (c *editorCmp) Blur() tea.Cmd {
357+ c.textarea.Blur()
358+ return nil
359+}
360+
361+// Focus implements Container.
362+func (c *editorCmp) Focus() tea.Cmd {
363+ logging.Info("Focusing editor textarea")
364+ return c.textarea.Focus()
365+}
366+
367+// IsFocused implements Container.
368+func (c *editorCmp) IsFocused() bool {
369+ return c.textarea.Focused()
370+}
371+
372+func NewEditorCmp(app *app.App) util.Model {
355 t := styles.CurrentTheme()
373 t := styles.CurrentTheme()
356 ta := textarea.New()
374 ta := textarea.New()
357 ta.SetStyles(t.S().TextArea)
375 ta.SetStyles(t.S().TextArea)
@@ -369,36 +387,8 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
369 ta.CharLimit = -1
387 ta.CharLimit = -1
370 ta.Placeholder = "Tell me more about this project..."
388 ta.Placeholder = "Tell me more about this project..."
371 ta.SetVirtualCursor(false)
389 ta.SetVirtualCursor(false)
372−
373− if existing != nil {
374− ta.SetValue(existing.Value())
375− ta.SetWidth(existing.Width())
376− ta.SetHeight(existing.Height())
377− }
378−
379 ta.Focus()
390 ta.Focus()
380− return ta
381−}
382
391
383−// Blur implements Container.
384−func (c *editorCmp) Blur() tea.Cmd {
385− c.textarea.Blur()
386− return nil
387−}
388−
389−// Focus implements Container.
390−func (c *editorCmp) Focus() tea.Cmd {
391− logging.Info("Focusing editor textarea")
392− return c.textarea.Focus()
393−}
394−
395−// IsFocused implements Container.
396−func (c *editorCmp) IsFocused() bool {
397− return c.textarea.Focused()
398−}
399−
400−func NewEditorCmp(app *app.App) util.Model {
401− ta := CreateTextArea(nil)
402 return &editorCmp{
392 return &editorCmp{
403 app: app,
393 app: app,
404 textarea: ta,
394 textarea: ta,
internal/tui/components/core/status/status.go+1−1
@@ -88,7 +88,7 @@ func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88
88
89func (m statusCmp) View() tea.View {
89func (m statusCmp) View() tea.View {
90 t := styles.CurrentTheme()
90 t := styles.CurrentTheme()
91− status := t.S().Base.Padding(0, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
91+ status := t.S().Base.Padding(0, 1, 1, 1).Render(m.help.View(DefaultKeyMap("focus chat")))
92 if m.info.Msg != "" {
92 if m.info.Msg != "" {
93 switch m.info.Type {
93 switch m.info.Type {
94 case util.InfoTypeError:
94 case util.InfoTypeError:
internal/tui/components/dialogs/filepicker/filepicker.go+44−2
@@ -1,13 +1,18 @@
1package filepicker
1package filepicker
2
2
3import (
3import (
4+ "fmt"
5+ "net/http"
4 "os"
6 "os"
7+ "path/filepath"
5 "strings"
8 "strings"
6
9
7 "github.com/charmbracelet/bubbles/v2/filepicker"
10 "github.com/charmbracelet/bubbles/v2/filepicker"
8 "github.com/charmbracelet/bubbles/v2/help"
11 "github.com/charmbracelet/bubbles/v2/help"
9 "github.com/charmbracelet/bubbles/v2/key"
12 "github.com/charmbracelet/bubbles/v2/key"
10 tea "github.com/charmbracelet/bubbletea/v2"
13 tea "github.com/charmbracelet/bubbletea/v2"
14+ "github.com/charmbracelet/crush/internal/logging"
15+ "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
12 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
17 "github.com/charmbracelet/crush/internal/tui/components/dialogs"
13 "github.com/charmbracelet/crush/internal/tui/components/image"
18 "github.com/charmbracelet/crush/internal/tui/components/image"
@@ -23,7 +28,7 @@ const (
23)
28)
24
29
25type FilePickedMsg struct {
30type FilePickedMsg struct {
26− FilePath string
31+ Attachment message.Attachment
27}
32}
28
33
29type FilePicker interface {
34type FilePicker interface {
@@ -111,7 +116,31 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111 // Get the path of the selected file.
116 // Get the path of the selected file.
112 return m, tea.Sequence(
117 return m, tea.Sequence(
113 util.CmdHandler(dialogs.CloseDialogMsg{}),
118 util.CmdHandler(dialogs.CloseDialogMsg{}),
119+ func() tea.Msg {
120+ isFileLarge, err := ValidateFileSize(path, maxAttachmentSize)
121+ if err != nil {
122+ logging.ErrorPersist("unable to read the image")
123+ return nil
124+ }
125+ if isFileLarge {
126+ logging.ErrorPersist("file too large, max 5MB")
127+ return nil
128+ }
129+
130+ content, err := os.ReadFile(path)
131+ if err != nil {
132+ logging.ErrorPersist("Unable read selected file")
133+ return nil
134+ }
135+
136+ mimeBufferSize := min(512, len(content))
137+ mimeType := http.DetectContentType(content[:mimeBufferSize])
138+ fileName := filepath.Base(path)
139+ attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
114− util.CmdHandler(FilePickedMsg{FilePath: path}),
140+ return FilePickedMsg{
141+ Attachment: attachment,
142+ }
143+ },
115 )
144 )
116 }
145 }
117 m.image, cmd = m.image.Update(msg)
146 m.image, cmd = m.image.Update(msg)
@@ -185,3 +214,16 @@ func (m *model) Position() (int, int) {
185 col -= m.width / 2
214 col -= m.width / 2
186 return row, col
215 return row, col
187}
216}
217+
218+func ValidateFileSize(filePath string, sizeLimit int64) (bool, error) {
219+ fileInfo, err := os.Stat(filePath)
220+ if err != nil {
221+ return false, fmt.Errorf("error getting file info: %w", err)
222+ }
223+
224+ if fileInfo.Size() > sizeLimit {
225+ return true, nil
226+ }
227+
228+ return false, nil
229+}
internal/tui/page/chat/chat.go+11−1
@@ -6,6 +6,8 @@ import (
6 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/crush/internal/app"
8 "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/llm/models"
9 "github.com/charmbracelet/crush/internal/logging"
11 "github.com/charmbracelet/crush/internal/logging"
10 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/session"
13 "github.com/charmbracelet/crush/internal/session"
@@ -87,7 +89,15 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87 )
89 )
88
90
89 case key.Matches(msg, p.keyMap.FilePicker):
91 case key.Matches(msg, p.keyMap.FilePicker):
92+ cfg := config.Get()
93+ agentCfg := cfg.Agents[config.AgentCoder]
94+ selectedModelID := agentCfg.Model
95+ model := models.SupportedModels[selectedModelID]
96+ if model.SupportsAttachments {
90− return p, util.CmdHandler(OpenFilePickerMsg{})
97+ return p, util.CmdHandler(OpenFilePickerMsg{})
98+ } else {
99+ return p, util.ReportWarn("File attachments are not supported by the current model: " + string(selectedModelID))
100+ }
91 case key.Matches(msg, p.keyMap.Tab):
101 case key.Matches(msg, p.keyMap.Tab):
92 logging.Info("Tab key pressed, toggling chat focus")
102 logging.Info("Tab key pressed, toggling chat focus")
93 if p.session.ID == "" {
103 if p.session.ID == "" {
internal/tui/tui.go+1−2
@@ -85,7 +85,6 @@ func (a appModel) Init() tea.Cmd {
85
85
86// Update handles incoming messages and updates the application state.
86// Update handles incoming messages and updates the application state.
87func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
87func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88− logging.Info("TUI Update", "msg", msg)
89 var cmds []tea.Cmd
88 var cmds []tea.Cmd
90 var cmd tea.Cmd
89 var cmd tea.Cmd
91
90
@@ -248,7 +247,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
248// handleWindowResize processes window resize events and updates all components.
247// handleWindowResize processes window resize events and updates all components.
249func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
248func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
250 var cmds []tea.Cmd
249 var cmds []tea.Cmd
251− msg.Height -= 1 // Make space for the status bar
250+ msg.Height -= 2 // Make space for the status bar
252 a.width, a.height = msg.Width, msg.Height
251 a.width, a.height = msg.Width, msg.Height
253
252
254 // Update status bar
253 // Update status bar