@@ -21,13 +21,14 @@ import (
2121 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
2222 "github.com/charmbracelet/crush/internal/app"
2323 "github.com/charmbracelet/crush/internal/config"
24+ "github.com/charmbracelet/crush/internal/filetracker"
2425 "github.com/charmbracelet/crush/internal/history"
2526 "github.com/charmbracelet/crush/internal/message"
2627 "github.com/charmbracelet/crush/internal/permission"
2728 "github.com/charmbracelet/crush/internal/pubsub"
2829 "github.com/charmbracelet/crush/internal/session"
29− "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
3030 "github.com/charmbracelet/crush/internal/ui/anim"
31+ "github.com/charmbracelet/crush/internal/ui/attachments"
3132 "github.com/charmbracelet/crush/internal/ui/chat"
3233 "github.com/charmbracelet/crush/internal/ui/common"
3334 "github.com/charmbracelet/crush/internal/ui/completions"
@@ -40,6 +41,12 @@ import (
4041 "github.com/charmbracelet/ultraviolet/screen"
4142)
4243
44+// Max file size set to 5M.
45+const maxAttachmentSize = int64(5 * 1024 * 1024)
46+
47+// Allowed image formats.
48+var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
49+
4350// uiFocusState represents the current focus state of the UI.
4451type uiFocusState uint8
4552
@@ -99,7 +106,8 @@ type UI struct {
99106 // Editor components
100107 textarea textarea.Model
101108
102− attachments []message.Attachment // TODO: Implement attachments
109+ // Attachment list
110+ attachments *attachments.Attachments
103111
104112 readyPlaceholder string
105113 workingPlaceholder string
@@ -141,6 +149,8 @@ func New(com *common.Common) *UI {
141149
142150 ch := NewChat(com)
143151
152+ keyMap := DefaultKeyMap()
153+
144154 // Completions component
145155 comp := completions.New(
146156 com.Styles.Completions.Normal,
@@ -148,15 +158,31 @@ func New(com *common.Common) *UI {
148158 com.Styles.Completions.Match,
149159 )
150160
161+ // Attachments component
162+ attachments := attachments.New(
163+ attachments.NewRenderer(
164+ com.Styles.Attachments.Normal,
165+ com.Styles.Attachments.Deleting,
166+ com.Styles.Attachments.Image,
167+ com.Styles.Attachments.Text,
168+ ),
169+ attachments.Keymap{
170+ DeleteMode: keyMap.Editor.AttachmentDeleteMode,
171+ DeleteAll: keyMap.Editor.DeleteAllAttachments,
172+ Escape: keyMap.Editor.Escape,
173+ },
174+ )
175+
151176 ui := &UI{
152177 com: com,
153178 dialog: dialog.NewOverlay(),
154− keyMap: DefaultKeyMap(),
179+ keyMap: keyMap,
155180 focus: uiFocusNone,
156181 state: uiConfigure,
157182 textarea: ta,
158183 chat: ch,
159184 completions: comp,
185+ attachments: attachments,
160186 }
161187
162188 status := NewStatus(com, ui)
@@ -393,6 +419,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
393419 }
394420 }
395421
422+ // at this point this can only handle [message.Attachment] message, and we
423+ // should return all cmds anyway.
424+ _ = m.attachments.Update(msg)
396425 return m, tea.Batch(cmds...)
397426}
398427
@@ -707,6 +736,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
707736 // Generic dialog messages
708737 case dialog.CloseMsg:
709738 m.dialog.CloseFrontDialog()
739+ if m.focus == uiFocusEditor {
740+ cmds = append(cmds, m.textarea.Focus())
741+ }
710742
711743 // Session dialog messages
712744 case dialog.SessionSelectedMsg:
@@ -803,7 +835,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
803835 case completions.SelectionMsg:
804836 // Handle file completion selection.
805837 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
806− m.insertFileCompletion(item.Path)
838+ cmds = append(cmds, m.insertFileCompletion(item.Path))
807839 }
808840 if !msg.Insert {
809841 m.closeCompletions()
@@ -815,6 +847,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
815847 }
816848 }
817849
850+ if ok := m.attachments.Update(msg); ok {
851+ return tea.Batch(cmds...)
852+ }
853+
818854 switch {
819855 case key.Matches(msg, m.keyMap.Editor.SendMessage):
820856 value := m.textarea.Value()
@@ -832,8 +868,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
832868 return m.openQuitDialog()
833869 }
834870
835− attachments := m.attachments
836− m.attachments = nil
871+ attachments := m.attachments.List()
872+ m.attachments.Reset()
837873 if len(value) == 0 {
838874 return nil
839875 }
@@ -1033,7 +1069,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
10331069 main := uv.NewStyledString(m.landingView())
10341070 main.Draw(scr, layout.main)
10351071
1036− editor := uv.NewStyledString(m.textarea.View())
1072+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
10371073 editor.Draw(scr, layout.editor)
10381074
10391075 case uiChat:
@@ -1043,7 +1079,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
10431079 header.Draw(scr, layout.header)
10441080 m.drawSidebar(scr, layout.sidebar)
10451081
1046− editor := uv.NewStyledString(m.textarea.View())
1082+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
10471083 editor.Draw(scr, layout.editor)
10481084
10491085 case uiChatCompact:
@@ -1057,7 +1093,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
10571093 main := uv.NewStyledString(mainView)
10581094 main.Draw(scr, layout.main)
10591095
1060− editor := uv.NewStyledString(m.textarea.View())
1096+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
10611097 editor.Draw(scr, layout.editor)
10621098 }
10631099
@@ -1128,6 +1164,10 @@ func (m *UI) Cursor() *tea.Cursor {
11281164 cur := m.textarea.Cursor()
11291165 cur.X++ // Adjust for app margins
11301166 cur.Y += m.layout.editor.Min.Y
1167+ // Offset for attachment row if present.
1168+ if len(m.attachments.List()) > 0 {
1169+ cur.Y++
1170+ }
11311171 return cur
11321172 }
11331173 }
@@ -1229,7 +1269,7 @@ func (m *UI) FullHelp() [][]key.Binding {
12291269 k := &m.keyMap
12301270 help := k.Help
12311271 help.SetHelp("ctrl+g", "less")
1232− hasAttachments := false // TODO: implement attachments
1272+ hasAttachments := len(m.attachments.List()) > 0
12331273 hasSession := m.session != nil && m.session.ID != ""
12341274 commands := k.Commands
12351275 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
@@ -1317,6 +1357,17 @@ func (m *UI) FullHelp() [][]key.Binding {
13171357 k.Editor.MentionFile,
13181358 k.Editor.OpenEditor,
13191359 },
1360+ )
1361+ if hasAttachments {
1362+ binds = append(binds,
1363+ []key.Binding{
1364+ k.Editor.AttachmentDeleteMode,
1365+ k.Editor.DeleteAllAttachments,
1366+ k.Editor.Escape,
1367+ },
1368+ )
1369+ }
1370+ binds = append(binds,
13201371 []key.Binding{
13211372 help,
13221373 },
@@ -1601,39 +1652,48 @@ func (m *UI) closeCompletions() {
16011652
16021653// insertFileCompletion inserts the selected file path into the textarea,
16031654// replacing the @query, and adds the file as an attachment.
1604−func (m *UI) insertFileCompletion(path string) {
1655+func (m *UI) insertFileCompletion(path string) tea.Cmd {
16051656 value := m.textarea.Value()
16061657 word := m.textareaWord()
16071658
16081659 // Find the @ and query to replace.
16091660 if m.completionsStartIndex > len(value) {
1610− return
1661+ return nil
16111662 }
16121663
16131664 // Build the new value: everything before @, the path, everything after query.
1614− endIdx := m.completionsStartIndex + len(word)
1615− if endIdx > len(value) {
1616− endIdx = len(value)
1617− }
1665+ endIdx := min(m.completionsStartIndex+len(word), len(value))
16181666
16191667 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
16201668 m.textarea.SetValue(newValue)
1621− // XXX: This will always move the cursor to the end of the textarea.
16221669 m.textarea.MoveToEnd()
1670+ m.textarea.InsertRune(' ')
1671+
1672+ return func() tea.Msg {
1673+ absPath, _ := filepath.Abs(path)
1674+ // Skip attachment if file was already read and hasn't been modified.
1675+ lastRead := filetracker.LastReadTime(absPath)
1676+ if !lastRead.IsZero() {
1677+ if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
1678+ return nil
1679+ }
1680+ }
16231681
1624− // Add file as attachment.
1625− content, err := os.ReadFile(path)
1626− if err != nil {
1627− // If it fails, let the LLM handle it later.
1628− return
1629− }
1682+ // Add file as attachment.
1683+ content, err := os.ReadFile(path)
1684+ if err != nil {
1685+ // If it fails, let the LLM handle it later.
1686+ return nil
1687+ }
1688+ filetracker.RecordRead(absPath)
16301689
1631− m.attachments = append(m.attachments, message.Attachment{
1632− FilePath: path,
1633− FileName: filepath.Base(path),
1634− MimeType: mimeOf(content),
1635− Content: content,
1636− })
1690+ return message.Attachment{
1691+ FilePath: path,
1692+ FileName: filepath.Base(path),
1693+ MimeType: mimeOf(content),
1694+ Content: content,
1695+ }
1696+ }
16371697}
16381698
16391699// completionsPosition returns the X and Y position for the completions popup.
@@ -1690,6 +1750,18 @@ func (m *UI) randomizePlaceholders() {
16901750 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
16911751}
16921752
1753+// renderEditorView renders the editor view with attachments if any.
1754+func (m *UI) renderEditorView(width int) string {
1755+ if len(m.attachments.List()) == 0 {
1756+ return m.textarea.View()
1757+ }
1758+ return lipgloss.JoinVertical(
1759+ lipgloss.Top,
1760+ m.attachments.Render(width),
1761+ m.textarea.View(),
1762+ )
1763+}
1764+
16931765// renderHeader renders and caches the header logo at the specified width.
16941766func (m *UI) renderHeader(compact bool, width int) {
16951767 // TODO: handle the compact case differently
@@ -1847,15 +1919,18 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
18471919
18481920 var cmd tea.Cmd
18491921 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1850− // try to get an image
1922+ // Try to get an image.
18511923 path, err := filepath.Abs(strings.TrimSpace(path))
18521924 if err != nil {
18531925 m.textarea, cmd = m.textarea.Update(msg)
18541926 return cmd
18551927 }
1928+
1929+ // Check if file has an allowed image extension.
18561930 isAllowedType := false
1857− for _, ext := range filepicker.AllowedTypes {
1858− if strings.HasSuffix(path, ext) {
1931+ lowerPath := strings.ToLower(path)
1932+ for _, ext := range allowedImageTypes {
1933+ if strings.HasSuffix(lowerPath, ext) {
18591934 isAllowedType = true
18601935 break
18611936 }
@@ -1864,24 +1939,31 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
18641939 m.textarea, cmd = m.textarea.Update(msg)
18651940 return cmd
18661941 }
1867− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1868− if tooBig {
1869− m.textarea, cmd = m.textarea.Update(msg)
1870− return cmd
1871− }
18721942
1873− content, err := os.ReadFile(path)
1874− if err != nil {
1875− m.textarea, cmd = m.textarea.Update(msg)
1876− return cmd
1943+ return func() tea.Msg {
1944+ fileInfo, err := os.Stat(path)
1945+ if err != nil {
1946+ return uiutil.ReportError(err)
1947+ }
1948+ if fileInfo.Size() > maxAttachmentSize {
1949+ return uiutil.ReportWarn("File is too big (>5mb)")
1950+ }
1951+
1952+ content, err := os.ReadFile(path)
1953+ if err != nil {
1954+ return uiutil.ReportError(err)
1955+ }
1956+
1957+ mimeBufferSize := min(512, len(content))
1958+ mimeType := http.DetectContentType(content[:mimeBufferSize])
1959+ fileName := filepath.Base(path)
1960+ return message.Attachment{
1961+ FilePath: path,
1962+ FileName: fileName,
1963+ MimeType: mimeType,
1964+ Content: content,
1965+ }
18771966 }
1878− mimeBufferSize := min(512, len(content))
1879− mimeType := http.DetectContentType(content[:mimeBufferSize])
1880− fileName := filepath.Base(path)
1881− attachment := message.Attachment{FilePath: path, FileName: fileName, MimeType: mimeType, Content: content}
1882− return uiutil.CmdHandler(filepicker.FilePickedMsg{
1883− Attachment: attachment,
1884− })
18851967}
18861968
18871969// renderLogo renders the Crush logo with the given styles and dimensions.