on main help sign in
feat: attachments (#1797)

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

commitb66db250ad9915f6957c79cd8a35c105e0812017 authorCarlos Alexandro Becker <caarlos0@users.noreply.github.com> dateThu 8 Jan 2026, 14:45 (9 months ago) parent6a57463
5 files changed+313−106
internal/ui/attachments/attachments.go+135 internal/ui/chat/messages.go+8−1 internal/ui/chat/user.go+14−46 internal/ui/model/ui.go+130−48 internal/ui/styles/styles.go+26−11
internal/ui/attachments/attachments.gonew file+135
@@ -0,0 +1,135 @@
1+package attachments
2+
3+import (
4+ "fmt"
5+ "math"
6+ "path/filepath"
7+ "slices"
8+ "strings"
9+
10+ "charm.land/bubbles/v2/key"
11+ tea "charm.land/bubbletea/v2"
12+ "charm.land/lipgloss/v2"
13+ "github.com/charmbracelet/crush/internal/message"
14+ "github.com/charmbracelet/x/ansi"
15+)
16+
17+const maxFilename = 15
18+
19+type Keymap struct {
20+ DeleteMode,
21+ DeleteAll,
22+ Escape key.Binding
23+}
24+
25+func New(renderer *Renderer, keyMap Keymap) *Attachments {
26+ return &Attachments{
27+ keyMap: keyMap,
28+ renderer: renderer,
29+ }
30+}
31+
32+type Attachments struct {
33+ renderer *Renderer
34+ keyMap Keymap
35+ list []message.Attachment
36+ deleting bool
37+}
38+
39+func (m *Attachments) List() []message.Attachment { return m.list }
40+func (m *Attachments) Reset() { m.list = nil }
41+
42+func (m *Attachments) Update(msg tea.Msg) bool {
43+ switch msg := msg.(type) {
44+ case message.Attachment:
45+ m.list = append(m.list, msg)
46+ return true
47+ case tea.KeyPressMsg:
48+ switch {
49+ case key.Matches(msg, m.keyMap.DeleteMode):
50+ if len(m.list) > 0 {
51+ m.deleting = true
52+ }
53+ return true
54+ case m.deleting && key.Matches(msg, m.keyMap.Escape):
55+ m.deleting = false
56+ return true
57+ case m.deleting && key.Matches(msg, m.keyMap.DeleteAll):
58+ m.deleting = false
59+ m.list = nil
60+ return true
61+ case m.deleting:
62+ // Handle digit keys for individual attachment deletion.
63+ r := msg.Code
64+ if r >= '0' && r <= '9' {
65+ num := int(r - '0')
66+ if num < len(m.list) {
67+ m.list = slices.Delete(m.list, num, num+1)
68+ }
69+ m.deleting = false
70+ }
71+ return true
72+ }
73+ }
74+ return false
75+}
76+
77+func (m *Attachments) Render(width int) string {
78+ return m.renderer.Render(m.list, m.deleting, width)
79+}
80+
81+func NewRenderer(normalStyle, deletingStyle, imageStyle, textStyle lipgloss.Style) *Renderer {
82+ return &Renderer{
83+ normalStyle: normalStyle,
84+ textStyle: textStyle,
85+ imageStyle: imageStyle,
86+ deletingStyle: deletingStyle,
87+ }
88+}
89+
90+type Renderer struct {
91+ normalStyle, textStyle, imageStyle, deletingStyle lipgloss.Style
92+}
93+
94+func (r *Renderer) Render(attachments []message.Attachment, deleting bool, width int) string {
95+ var chips []string
96+
97+ maxItemWidth := lipgloss.Width(r.imageStyle.String() + r.normalStyle.Render(strings.Repeat("x", maxFilename)))
98+ fits := int(math.Floor(float64(width)/float64(maxItemWidth))) - 1
99+
100+ for i, att := range attachments {
101+ filename := filepath.Base(att.FileName)
102+ // Truncate if needed.
103+ if ansi.StringWidth(filename) > maxFilename {
104+ filename = ansi.Truncate(filename, maxFilename, "…")
105+ }
106+
107+ if deleting {
108+ chips = append(
109+ chips,
110+ r.deletingStyle.Render(fmt.Sprintf("%d", i)),
111+ r.normalStyle.Render(filename),
112+ )
113+ } else {
114+ chips = append(
115+ chips,
116+ r.icon(att).String(),
117+ r.normalStyle.Render(filename),
118+ )
119+ }
120+
121+ if i == fits && len(attachments) > i {
122+ chips = append(chips, lipgloss.NewStyle().Width(maxItemWidth).Render(fmt.Sprintf("%d more…", len(attachments)-fits)))
123+ break
124+ }
125+ }
126+
127+ return lipgloss.JoinHorizontal(lipgloss.Left, chips...)
128+}
129+
130+func (r *Renderer) icon(a message.Attachment) lipgloss.Style {
131+ if a.IsImage() {
132+ return r.imageStyle
133+ }
134+ return r.textStyle
135+}
internal/ui/chat/messages.go+8−1
@@ -10,6 +10,7 @@ import (
1010 tea "charm.land/bubbletea/v2"
1111 "github.com/charmbracelet/crush/internal/message"
1212 "github.com/charmbracelet/crush/internal/ui/anim"
13+ "github.com/charmbracelet/crush/internal/ui/attachments"
1314 "github.com/charmbracelet/crush/internal/ui/list"
1415 "github.com/charmbracelet/crush/internal/ui/styles"
1516)
@@ -158,7 +159,13 @@ func cappedMessageWidth(availableWidth int) int {
158159func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
159160 switch msg.Role {
160161 case message.User:
161− return []MessageItem{NewUserMessageItem(sty, msg)}
162+ r := attachments.NewRenderer(
163+ sty.Attachments.Normal,
164+ sty.Attachments.Deleting,
165+ sty.Attachments.Image,
166+ sty.Attachments.Text,
167+ )
168+ return []MessageItem{NewUserMessageItem(sty, msg, r)}
162169 case message.Assistant:
163170 var items []MessageItem
164171 if ShouldRenderAssistantMessage(msg) {
internal/ui/chat/user.go+14−46
@@ -1,15 +1,13 @@
11package chat
22
33import (
4− "fmt"
5− "path/filepath"
64 "strings"
75
86 "charm.land/lipgloss/v2"
97 "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/attachments"
109 "github.com/charmbracelet/crush/internal/ui/common"
1110 "github.com/charmbracelet/crush/internal/ui/styles"
12− "github.com/charmbracelet/x/ansi"
1311)
1412
1513// UserMessageItem represents a user message in the chat UI.
@@ -18,16 +16,18 @@ type UserMessageItem struct {
1816 *cachedMessageItem
1917 *focusableMessageItem
2018
21− message *message.Message
22− sty *styles.Styles
19+ attachments *attachments.Renderer
20+ message *message.Message
21+ sty *styles.Styles
2322}
2423
2524// NewUserMessageItem creates a new UserMessageItem.
26−func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
25+func NewUserMessageItem(sty *styles.Styles, message *message.Message, attachments *attachments.Renderer) MessageItem {
2726 return &UserMessageItem{
2827 highlightableMessageItem: defaultHighlighter(sty),
2928 cachedMessageItem: &cachedMessageItem{},
3029 focusableMessageItem: &focusableMessageItem{},
30+ attachments: attachments,
3131 message: message,
3232 sty: sty,
3333 }
@@ -73,46 +73,14 @@ func (m *UserMessageItem) ID() string {
7373 return m.message.ID
7474}
7575
76−// renderAttachments renders attachments with wrapping if they exceed the width.
77−// TODO: change the styles here so they match the new design
76+// renderAttachments renders attachments.
7877func (m *UserMessageItem) renderAttachments(width int) string {
79− const maxFilenameWidth = 10
80−
81− attachments := make([]string, len(m.message.BinaryContent()))
82− for i, attachment := range m.message.BinaryContent() {
83− filename := filepath.Base(attachment.Path)
84− attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
85− " %s %s ",
86− styles.DocumentIcon,
87− ansi.Truncate(filename, maxFilenameWidth, "…"),
88− ))
89− }
90−
91− // Wrap attachments into lines that fit within the width.
92− var lines []string
93− var currentLine []string
94− currentWidth := 0
95−
96− for _, att := range attachments {
97− attWidth := lipgloss.Width(att)
98− sepWidth := 1
99− if len(currentLine) == 0 {
100− sepWidth = 0
101− }
102−
103− if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 {
104− lines = append(lines, strings.Join(currentLine, " "))
105− currentLine = []string{att}
106− currentWidth = attWidth
107− } else {
108− currentLine = append(currentLine, att)
109− currentWidth += sepWidth + attWidth
110− }
111− }
112−
113− if len(currentLine) > 0 {
114− lines = append(lines, strings.Join(currentLine, " "))
78+ var attachments []message.Attachment
79+ for _, at := range m.message.BinaryContent() {
80+ attachments = append(attachments, message.Attachment{
81+ FileName: at.Path,
82+ MimeType: at.MIMEType,
83+ })
11584 }
116−
117− return strings.Join(lines, "\n")
85+ return m.attachments.Render(attachments, false, width)
11886}
internal/ui/model/ui.go+130−48
@@ -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.
internal/ui/styles/styles.go+26−11
@@ -16,15 +16,14 @@ import (
1616)
1717
1818const (
19− CheckIcon string = "✓"
20− ErrorIcon string = "×"
21− WarningIcon string = "⚠"
22− InfoIcon string = "ⓘ"
23− HintIcon string = "∵"
24− SpinnerIcon string = "..."
25− LoadingIcon string = "⟳"
26− DocumentIcon string = "🖼"
27− ModelIcon string = "◇"
19+ CheckIcon string = "✓"
20+ ErrorIcon string = "×"
21+ WarningIcon string = "⚠"
22+ InfoIcon string = "ⓘ"
23+ HintIcon string = "∵"
24+ SpinnerIcon string = "..."
25+ LoadingIcon string = "⟳"
26+ ModelIcon string = "◇"
2827
2928 ArrowRightIcon string = "→"
3029
@@ -43,6 +42,9 @@ const (
4342 TodoCompletedIcon string = "✓"
4443 TodoPendingIcon string = "•"
4544 TodoInProgressIcon string = "→"
45+
46+ ImageIcon = "■"
47+ TextIcon = "≡"
4648)
4749
4850const (
@@ -208,7 +210,6 @@ type Styles struct {
208210 ErrorTag lipgloss.Style
209211 ErrorTitle lipgloss.Style
210212 ErrorDetails lipgloss.Style
211− Attachment lipgloss.Style
212213 ToolCallFocused lipgloss.Style
213214 ToolCallCompact lipgloss.Style
214215 ToolCallBlurred lipgloss.Style
@@ -337,6 +338,14 @@ type Styles struct {
337338 Focused lipgloss.Style
338339 Match lipgloss.Style
339340 }
341+
342+ // Attachments styles
343+ Attachments struct {
344+ Normal lipgloss.Style
345+ Image lipgloss.Style
346+ Text lipgloss.Style
347+ Deleting lipgloss.Style
348+ }
340349}
341350
342351// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1119,7 +1128,6 @@ func DefaultStyles() Styles {
11191128 s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
11201129
11211130 // Message item styles
1122− s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
11231131 s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
11241132 BorderStyle(messageFocussedBorder).
11251133 BorderLeft(true).
@@ -1172,6 +1180,13 @@ func DefaultStyles() Styles {
11721180 s.Completions.Focused = base.Background(primary).Foreground(white)
11731181 s.Completions.Match = base.Underline(true)
11741182
1183+ // Attachments styles
1184+ attachmentIconStyle := base.Foreground(bgSubtle).Background(green).Padding(0, 1)
1185+ s.Attachments.Image = attachmentIconStyle.SetString(ImageIcon)
1186+ s.Attachments.Text = attachmentIconStyle.SetString(TextIcon)
1187+ s.Attachments.Normal = base.Padding(0, 1).MarginRight(1).Background(fgMuted).Foreground(fgBase)
1188+ s.Attachments.Deleting = base.Padding(0, 1).Bold(true).Background(red).Foreground(fgBase)
1189+
11751190 return s
11761191}
11771192