feat: attachments (#1797)

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

internal/ui/attachments/attachments.go | 135 +++++++++++++++++++++
internal/ui/chat/messages.go           |   9 +
internal/ui/chat/user.go               |  60 ++-------
internal/ui/model/ui.go                | 178 ++++++++++++++++++++-------
internal/ui/styles/styles.go           |  37 ++++-
5 files changed, 313 insertions(+), 106 deletions(-)

Detailed changes

internal/ui/attachments/attachments.go πŸ”—

@@ -0,0 +1,135 @@
+package attachments
+
+import (
+	"fmt"
+	"math"
+	"path/filepath"
+	"slices"
+	"strings"
+
+	"charm.land/bubbles/v2/key"
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/x/ansi"
+)
+
+const maxFilename = 15
+
+type Keymap struct {
+	DeleteMode,
+	DeleteAll,
+	Escape key.Binding
+}
+
+func New(renderer *Renderer, keyMap Keymap) *Attachments {
+	return &Attachments{
+		keyMap:   keyMap,
+		renderer: renderer,
+	}
+}
+
+type Attachments struct {
+	renderer *Renderer
+	keyMap   Keymap
+	list     []message.Attachment
+	deleting bool
+}
+
+func (m *Attachments) List() []message.Attachment { return m.list }
+func (m *Attachments) Reset()                     { m.list = nil }
+
+func (m *Attachments) Update(msg tea.Msg) bool {
+	switch msg := msg.(type) {
+	case message.Attachment:
+		m.list = append(m.list, msg)
+		return true
+	case tea.KeyPressMsg:
+		switch {
+		case key.Matches(msg, m.keyMap.DeleteMode):
+			if len(m.list) > 0 {
+				m.deleting = true
+			}
+			return true
+		case m.deleting && key.Matches(msg, m.keyMap.Escape):
+			m.deleting = false
+			return true
+		case m.deleting && key.Matches(msg, m.keyMap.DeleteAll):
+			m.deleting = false
+			m.list = nil
+			return true
+		case m.deleting:
+			// Handle digit keys for individual attachment deletion.
+			r := msg.Code
+			if r >= '0' && r <= '9' {
+				num := int(r - '0')
+				if num < len(m.list) {
+					m.list = slices.Delete(m.list, num, num+1)
+				}
+				m.deleting = false
+			}
+			return true
+		}
+	}
+	return false
+}
+
+func (m *Attachments) Render(width int) string {
+	return m.renderer.Render(m.list, m.deleting, width)
+}
+
+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
+	return &Renderer{
+		normalStyle:   normalStyle,
+		textStyle:     textStyle,
+		imageStyle:    imageStyle,
+		deletingStyle: deletingStyle,
+	}
+}
+
+type Renderer struct {
+	normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
+}
+
+func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
+	var chips []string
+
+	maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename)))
+	fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1
+
+	for i, att := range attachments {
+		filename := filepath.Base(att.FileName)
+		// Truncate if needed.
+		if ansi.StringWidth(filename) > maxFilename {
+			filename = ansi.Truncate(filename, maxFilename, "…")
+		}
+
+		if deleting {
+			chips = append(
+				chips,
+				r.deletingStyle.Render(fmt.Sprintf("%d", i)),
+				r.normalStyle.Render(filename),
+			)
+		} else {
+			chips = append(
+				chips,
+				r.icon(att).String(),
+				r.normalStyle.Render(filename),
+			)
+		}
+
+		if i == fits && len(attachments) > i {
+			chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
+			break
+		}
+	}
+
+	return lipgloss.JoinHorizontal(lipgloss.Left, chips...)
+}
+
+func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
+	if a.IsImage() {
+		return r.imageStyle
+	}
+	return r.textStyle
+}

internal/ui/chat/messages.go πŸ”—

@@ -10,6 +10,7 @@ import (
 	tea "charm.land/bubbletea/v2"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
@@ -158,7 +159,13 @@ func cappedMessageWidth(availableWidth int) int {
 func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
 	switch msg.Role {
 	case message.User:
-		return []MessageItem{NewUserMessageItem(sty, msg)}
+		r := attachments.NewRenderer(
+			sty.Attachments.Normal,
+			sty.Attachments.Deleting,
+			sty.Attachments.Image,
+			sty.Attachments.Text,
+		)
+		return []MessageItem{NewUserMessageItem(sty, msg, r)}
 	case message.Assistant:
 		var items []MessageItem
 		if ShouldRenderAssistantMessage(msg) {

internal/ui/chat/user.go πŸ”—

@@ -1,15 +1,13 @@
 package chat
 
 import (
-	"fmt"
-	"path/filepath"
 	"strings"
 
 	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // UserMessageItem represents a user message in the chat UI.
@@ -18,16 +16,18 @@ type UserMessageItem struct {
 	*cachedMessageItem
 	*focusableMessageItem
 
-	message *message.Message
-	sty     *styles.Styles
+	attachments *attachments.Renderer
+	message     *message.Message
+	sty         *styles.Styles
 }
 
 // NewUserMessageItem creates a new UserMessageItem.
-func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
+func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
 	return &UserMessageItem{
 		highlightableMessageItem: defaultHighlighter(sty),
 		cachedMessageItem:        &cachedMessageItem{},
 		focusableMessageItem:     &focusableMessageItem{},
+		attachments:              attachments,
 		message:                  message,
 		sty:                      sty,
 	}
@@ -73,46 +73,14 @@ func (m *UserMessageItem) ID() string {
 	return m.message.ID
 }
 
-// renderAttachments renders attachments with wrapping if they exceed the width.
-// TODO: change the styles here so they match the new design
+// renderAttachments renders attachments.
 func (m *UserMessageItem) renderAttachments(width int) string {
-	const maxFilenameWidth = 10
-
-	attachments := make([]string, len(m.message.BinaryContent()))
-	for i, attachment := range m.message.BinaryContent() {
-		filename := filepath.Base(attachment.Path)
-		attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
-			" %s %s ",
-			styles.DocumentIcon,
-			ansi.Truncate(filename, maxFilenameWidth, "…"),
-		))
-	}
-
-	// Wrap attachments into lines that fit within the width.
-	var lines []string
-	var currentLine []string
-	currentWidth := 0
-
-	for _, att := range attachments {
-		attWidth := lipgloss.Width(att)
-		sepWidth := 1
-		if len(currentLine) == 0 {
-			sepWidth = 0
-		}
-
-		if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 {
-			lines = append(lines, strings.Join(currentLine, " "))
-			currentLine = []string{att}
-			currentWidth = attWidth
-		} else {
-			currentLine = append(currentLine, att)
-			currentWidth += sepWidth + attWidth
-		}
-	}
-
-	if len(currentLine) > 0 {
-		lines = append(lines, strings.Join(currentLine, " "))
+	var attachments []message.Attachment
+	for _, at := range m.message.BinaryContent() {
+		attachments = append(attachments, message.Attachment{
+			FileName: at.Path,
+			MimeType: at.MIMEType,
+		})
 	}
-
-	return strings.Join(lines, "\n")
+	return m.attachments.Render(attachments, false, width)
 }

internal/ui/model/ui.go πŸ”—

@@ -21,13 +21,14 @@ import (
 	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
 	"github.com/charmbracelet/crush/internal/app"
 	"github.com/charmbracelet/crush/internal/config"
+	"github.com/charmbracelet/crush/internal/filetracker"
 	"github.com/charmbracelet/crush/internal/history"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/permission"
 	"github.com/charmbracelet/crush/internal/pubsub"
 	"github.com/charmbracelet/crush/internal/session"
-	"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
 	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/attachments"
 	"github.com/charmbracelet/crush/internal/ui/chat"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/completions"
@@ -40,6 +41,12 @@ import (
 	"github.com/charmbracelet/ultraviolet/screen"
 )
 
+// Max file size set to 5M.
+const maxAttachmentSize = int64(5 * 1024 * 1024)
+
+// Allowed image formats.
+var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
+
 // uiFocusState represents the current focus state of the UI.
 type uiFocusState uint8
 
@@ -99,7 +106,8 @@ type UI struct {
 	// Editor components
 	textarea textarea.Model
 
-	attachments []message.Attachment // TODO: Implement attachments
+	// Attachment list
+	attachments *attachments.Attachments
 
 	readyPlaceholder   string
 	workingPlaceholder string
@@ -141,6 +149,8 @@ func New(com *common.Common) *UI {
 
 	ch := NewChat(com)
 
+	keyMap := DefaultKeyMap()
+
 	// Completions component
 	comp := completions.New(
 		com.Styles.Completions.Normal,
@@ -148,15 +158,31 @@ func New(com *common.Common) *UI {
 		com.Styles.Completions.Match,
 	)
 
+	// Attachments component
+	attachments := attachments.New(
+		attachments.NewRenderer(
+			com.Styles.Attachments.Normal,
+			com.Styles.Attachments.Deleting,
+			com.Styles.Attachments.Image,
+			com.Styles.Attachments.Text,
+		),
+		attachments.Keymap{
+			DeleteMode: keyMap.Editor.AttachmentDeleteMode,
+			DeleteAll:  keyMap.Editor.DeleteAllAttachments,
+			Escape:     keyMap.Editor.Escape,
+		},
+	)
+
 	ui := &UI{
 		com:         com,
 		dialog:      dialog.NewOverlay(),
-		keyMap:      DefaultKeyMap(),
+		keyMap:      keyMap,
 		focus:       uiFocusNone,
 		state:       uiConfigure,
 		textarea:    ta,
 		chat:        ch,
 		completions: comp,
+		attachments: attachments,
 	}
 
 	status := NewStatus(com, ui)
@@ -393,6 +419,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 
+	// at this point this can only handle [message.Attachment] message, and we
+	// should return all cmds anyway.
+	_ = m.attachments.Update(msg)
 	return m, tea.Batch(cmds...)
 }
 
@@ -707,6 +736,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		// Generic dialog messages
 		case dialog.CloseMsg:
 			m.dialog.CloseFrontDialog()
+			if m.focus == uiFocusEditor {
+				cmds = append(cmds, m.textarea.Focus())
+			}
 
 		// Session dialog messages
 		case dialog.SessionSelectedMsg:
@@ -803,7 +835,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					case completions.SelectionMsg:
 						// Handle file completion selection.
 						if item, ok := msg.Value.(completions.FileCompletionValue); ok {
-							m.insertFileCompletion(item.Path)
+							cmds = append(cmds, m.insertFileCompletion(item.Path))
 						}
 						if !msg.Insert {
 							m.closeCompletions()
@@ -815,6 +847,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				}
 			}
 
+			if ok := m.attachments.Update(msg); ok {
+				return tea.Batch(cmds...)
+			}
+
 			switch {
 			case key.Matches(msg, m.keyMap.Editor.SendMessage):
 				value := m.textarea.Value()
@@ -832,8 +868,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 					return m.openQuitDialog()
 				}
 
-				attachments := m.attachments
-				m.attachments = nil
+				attachments := m.attachments.List()
+				m.attachments.Reset()
 				if len(value) == 0 {
 					return nil
 				}
@@ -1033,7 +1069,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		main := uv.NewStyledString(m.landingView())
 		main.Draw(scr, layout.main)
 
-		editor := uv.NewStyledString(m.textarea.View())
+		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
 		editor.Draw(scr, layout.editor)
 
 	case uiChat:
@@ -1043,7 +1079,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		header.Draw(scr, layout.header)
 		m.drawSidebar(scr, layout.sidebar)
 
-		editor := uv.NewStyledString(m.textarea.View())
+		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
 		editor.Draw(scr, layout.editor)
 
 	case uiChatCompact:
@@ -1057,7 +1093,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
 		main := uv.NewStyledString(mainView)
 		main.Draw(scr, layout.main)
 
-		editor := uv.NewStyledString(m.textarea.View())
+		editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
 		editor.Draw(scr, layout.editor)
 	}
 
@@ -1128,6 +1164,10 @@ func (m *UI) Cursor() *tea.Cursor {
 			cur := m.textarea.Cursor()
 			cur.X++ // Adjust for app margins
 			cur.Y += m.layout.editor.Min.Y
+			// Offset for attachment row if present.
+			if len(m.attachments.List()) > 0 {
+				cur.Y++
+			}
 			return cur
 		}
 	}
@@ -1229,7 +1269,7 @@ func (m *UI) FullHelp() [][]key.Binding {
 	k := &m.keyMap
 	help := k.Help
 	help.SetHelp("ctrl+g", "less")
-	hasAttachments := false // TODO: implement attachments
+	hasAttachments := len(m.attachments.List()) > 0
 	hasSession := m.session != nil && m.session.ID != ""
 	commands := k.Commands
 	if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
@@ -1317,6 +1357,17 @@ func (m *UI) FullHelp() [][]key.Binding {
 					k.Editor.MentionFile,
 					k.Editor.OpenEditor,
 				},
+			)
+			if hasAttachments {
+				binds = append(binds,
+					[]key.Binding{
+						k.Editor.AttachmentDeleteMode,
+						k.Editor.DeleteAllAttachments,
+						k.Editor.Escape,
+					},
+				)
+			}
+			binds = append(binds,
 				[]key.Binding{
 					help,
 				},
@@ -1601,39 +1652,48 @@ func (m *UI) closeCompletions() {
 
 // insertFileCompletion inserts the selected file path into the textarea,
 // replacing the @query, and adds the file as an attachment.
-func (m *UI) insertFileCompletion(path string) {
+func (m *UI) insertFileCompletion(path string) tea.Cmd {
 	value := m.textarea.Value()
 	word := m.textareaWord()
 
 	// Find the @ and query to replace.
 	if m.completionsStartIndex > len(value) {
-		return
+		return nil
 	}
 
 	// Build the new value: everything before @, the path, everything after query.
-	endIdx := m.completionsStartIndex + len(word)
-	if endIdx > len(value) {
-		endIdx = len(value)
-	}
+	endIdx := min(m.completionsStartIndex+len(word), len(value))
 
 	newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
 	m.textarea.SetValue(newValue)
-	// XXX: This will always move the cursor to the end of the textarea.
 	m.textarea.MoveToEnd()
+	m.textarea.InsertRune(' ')
+
+	return func() tea.Msg {
+		absPath, _ := filepath.Abs(path)
+		// Skip attachment if file was already read and hasn't been modified.
+		lastRead := filetracker.LastReadTime(absPath)
+		if !lastRead.IsZero() {
+			if info, err := os.Stat(path); err == nil && !info.ModTime().After(lastRead) {
+				return nil
+			}
+		}
 
-	// Add file as attachment.
-	content, err := os.ReadFile(path)
-	if err != nil {
-		// If it fails, let the LLM handle it later.
-		return
-	}
+		// Add file as attachment.
+		content, err := os.ReadFile(path)
+		if err != nil {
+			// If it fails, let the LLM handle it later.
+			return nil
+		}
+		filetracker.RecordRead(absPath)
 
-	m.attachments = append(m.attachments, message.Attachment{
-		FilePath: path,
-		FileName: filepath.Base(path),
-		MimeType: mimeOf(content),
-		Content:  content,
-	})
+		return message.Attachment{
+			FilePath: path,
+			FileName: filepath.Base(path),
+			MimeType: mimeOf(content),
+			Content:  content,
+		}
+	}
 }
 
 // completionsPosition returns the X and Y position for the completions popup.
@@ -1690,6 +1750,18 @@ func (m *UI) randomizePlaceholders() {
 	m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
 }
 
+// renderEditorView renders the editor view with attachments if any.
+func (m *UI) renderEditorView(width int) string {
+	if len(m.attachments.List()) == 0 {
+		return m.textarea.View()
+	}
+	return lipgloss.JoinVertical(
+		lipgloss.Top,
+		m.attachments.Render(width),
+		m.textarea.View(),
+	)
+}
+
 // renderHeader renders and caches the header logo at the specified width.
 func (m *UI) renderHeader(compact bool, width int) {
 	// TODO: handle the compact case differently
@@ -1847,15 +1919,18 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 
 	var cmd tea.Cmd
 	path := strings.ReplaceAll(msg.Content, "\\ ", " ")
-	// try to get an image
+	// Try to get an image.
 	path, err := filepath.Abs(strings.TrimSpace(path))
 	if err != nil {
 		m.textarea, cmd = m.textarea.Update(msg)
 		return cmd
 	}
+
+	// Check if file has an allowed image extension.
 	isAllowedType := false
-	for _, ext := range filepicker.AllowedTypes {
-		if strings.HasSuffix(path, ext) {
+	lowerPath := strings.ToLower(path)
+	for _, ext := range allowedImageTypes {
+		if strings.HasSuffix(lowerPath, ext) {
 			isAllowedType = true
 			break
 		}
@@ -1864,24 +1939,31 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 		m.textarea, cmd = m.textarea.Update(msg)
 		return cmd
 	}
-	tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
-	if tooBig {
-		m.textarea, cmd = m.textarea.Update(msg)
-		return cmd
-	}
 
-	content, err := os.ReadFile(path)
-	if err != nil {
-		m.textarea, cmd = m.textarea.Update(msg)
-		return cmd
+	return func() tea.Msg {
+		fileInfo, err := os.Stat(path)
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+		if fileInfo.Size() > maxAttachmentSize {
+			return uiutil.ReportWarn("File is too big (>5mb)")
+		}
+
+		content, err := os.ReadFile(path)
+		if err != nil {
+			return uiutil.ReportError(err)
+		}
+
+		mimeBufferSize := min(512, len(content))
+		mimeType := http.DetectContentType(content[:mimeBufferSize])
+		fileName := filepath.Base(path)
+		return message.Attachment{
+			FilePath: path,
+			FileName: fileName,
+			MimeType: mimeType,
+			Content:  content,
+		}
 	}
-	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 uiutil.CmdHandler(filepicker.FilePickedMsg{
-		Attachment: attachment,
-	})
 }
 
 // renderLogo renders the Crush logo with the given styles and dimensions.

internal/ui/styles/styles.go πŸ”—

@@ -16,15 +16,14 @@ import (
 )
 
 const (
-	CheckIcon    string = "βœ“"
-	ErrorIcon    string = "Γ—"
-	WarningIcon  string = "⚠"
-	InfoIcon     string = "β“˜"
-	HintIcon     string = "∡"
-	SpinnerIcon  string = "..."
-	LoadingIcon  string = "⟳"
-	DocumentIcon string = "πŸ–Ό"
-	ModelIcon    string = "β—‡"
+	CheckIcon   string = "βœ“"
+	ErrorIcon   string = "Γ—"
+	WarningIcon string = "⚠"
+	InfoIcon    string = "β“˜"
+	HintIcon    string = "∡"
+	SpinnerIcon string = "..."
+	LoadingIcon string = "⟳"
+	ModelIcon   string = "β—‡"
 
 	ArrowRightIcon string = "β†’"
 
@@ -43,6 +42,9 @@ const (
 	TodoCompletedIcon  string = "βœ“"
 	TodoPendingIcon    string = "β€’"
 	TodoInProgressIcon string = "β†’"
+
+	ImageIcon = "β– "
+	TextIcon  = "≑"
 )
 
 const (
@@ -208,7 +210,6 @@ type Styles struct {
 			ErrorTag         lipgloss.Style
 			ErrorTitle       lipgloss.Style
 			ErrorDetails     lipgloss.Style
-			Attachment       lipgloss.Style
 			ToolCallFocused  lipgloss.Style
 			ToolCallCompact  lipgloss.Style
 			ToolCallBlurred  lipgloss.Style
@@ -337,6 +338,14 @@ type Styles struct {
 		Focused lipgloss.Style
 		Match   lipgloss.Style
 	}
+
+	// Attachments styles
+	Attachments struct {
+		Normal   lipgloss.Style
+		Image    lipgloss.Style
+		Text     lipgloss.Style
+		Deleting lipgloss.Style
+	}
 }
 
 // ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1119,7 +1128,6 @@ func DefaultStyles() Styles {
 	s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
 
 	// Message item styles
-	s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
 	s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
 		BorderStyle(messageFocussedBorder).
 		BorderLeft(true).
@@ -1172,6 +1180,13 @@ func DefaultStyles() Styles {
 	s.Completions.Focused = base.Background(primary).Foreground(white)
 	s.Completions.Match = base.Underline(true)
 
+	// Attachments styles
+	attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1)
+	s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
+	s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
+	s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
+	s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
+
 	return s
 }