on main help sign in
feat: attachments (#1797)
b66db25Carlos Alexandro Becker
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 (
10 tea "charm.land/bubbletea/v2"
10 tea "charm.land/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
12 "github.com/charmbracelet/crush/internal/ui/anim"
12 "github.com/charmbracelet/crush/internal/ui/anim"
13+ "github.com/charmbracelet/crush/internal/ui/attachments"
13 "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/list"
14 "github.com/charmbracelet/crush/internal/ui/styles"
15 "github.com/charmbracelet/crush/internal/ui/styles"
15)
16)
@@ -158,7 +159,13 @@ func cappedMessageWidth(availableWidth int) int {
158func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
159func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
159 switch msg.Role {
160 switch msg.Role {
160 case message.User:
161 case message.User:
162+ r := attachments.NewRenderer(
163+ sty.Attachments.Normal,
164+ sty.Attachments.Deleting,
165+ sty.Attachments.Image,
166+ sty.Attachments.Text,
167+ )
161− return []MessageItem{NewUserMessageItem(sty, msg)}
168+ return []MessageItem{NewUserMessageItem(sty, msg, r)}
162 case message.Assistant:
169 case message.Assistant:
163 var items []MessageItem
170 var items []MessageItem
164 if ShouldRenderAssistantMessage(msg) {
171 if ShouldRenderAssistantMessage(msg) {
internal/ui/chat/user.go+14−46
@@ -1,15 +1,13 @@
1package chat
1package chat
2
2
3import (
3import (
4− "fmt"
5− "path/filepath"
6 "strings"
4 "strings"
7
5
8 "charm.land/lipgloss/v2"
6 "charm.land/lipgloss/v2"
9 "github.com/charmbracelet/crush/internal/message"
7 "github.com/charmbracelet/crush/internal/message"
8+ "github.com/charmbracelet/crush/internal/ui/attachments"
10 "github.com/charmbracelet/crush/internal/ui/common"
9 "github.com/charmbracelet/crush/internal/ui/common"
11 "github.com/charmbracelet/crush/internal/ui/styles"
10 "github.com/charmbracelet/crush/internal/ui/styles"
12− "github.com/charmbracelet/x/ansi"
13)
11)
14
12
15// UserMessageItem represents a user message in the chat UI.
13// UserMessageItem represents a user message in the chat UI.
@@ -18,16 +16,18 @@ type UserMessageItem struct {
18 *cachedMessageItem
16 *cachedMessageItem
19 *focusableMessageItem
17 *focusableMessageItem
20
18
19+ attachments *attachments.Renderer
21− message *message.Message
20+ message *message.Message
22− sty *styles.Styles
21+ sty *styles.Styles
23}
22}
24
23
25// NewUserMessageItem creates a new UserMessageItem.
24// 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 {
27 return &UserMessageItem{
26 return &UserMessageItem{
28 highlightableMessageItem: defaultHighlighter(sty),
27 highlightableMessageItem: defaultHighlighter(sty),
29 cachedMessageItem: &cachedMessageItem{},
28 cachedMessageItem: &cachedMessageItem{},
30 focusableMessageItem: &focusableMessageItem{},
29 focusableMessageItem: &focusableMessageItem{},
30+ attachments: attachments,
31 message: message,
31 message: message,
32 sty: sty,
32 sty: sty,
33 }
33 }
@@ -73,46 +73,14 @@ func (m *UserMessageItem) ID() string {
73 return m.message.ID
73 return m.message.ID
74}
74}
75
75
76−// renderAttachments renders attachments with wrapping if they exceed the width.
76+// renderAttachments renders attachments.
77−// TODO: change the styles here so they match the new design
78func (m *UserMessageItem) renderAttachments(width int) string {
77func (m *UserMessageItem) renderAttachments(width int) string {
79− const maxFilenameWidth = 10
80−
81− attachments := make([]string, len(m.message.BinaryContent()))
78+ var attachments []message.Attachment
82− for i, attachment := range m.message.BinaryContent() {
79+ for _, at := range m.message.BinaryContent() {
83− filename := filepath.Base(attachment.Path)
80+ attachments = append(attachments, message.Attachment{
84− attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
81+ FileName: at.Path,
85− " %s %s ",
82+ MimeType: at.MIMEType,
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− }
83+ })
111− }
112−
113− if len(currentLine) > 0 {
114− lines = append(lines, strings.Join(currentLine, " "))
115 }
84 }
116−
85+ return m.attachments.Render(attachments, false, width)
117− return strings.Join(lines, "\n")
118}
86}
internal/ui/model/ui.go+130−48
@@ -21,13 +21,14 @@ import (
21 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
21 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
22 "github.com/charmbracelet/crush/internal/app"
22 "github.com/charmbracelet/crush/internal/app"
23 "github.com/charmbracelet/crush/internal/config"
23 "github.com/charmbracelet/crush/internal/config"
24+ "github.com/charmbracelet/crush/internal/filetracker"
24 "github.com/charmbracelet/crush/internal/history"
25 "github.com/charmbracelet/crush/internal/history"
25 "github.com/charmbracelet/crush/internal/message"
26 "github.com/charmbracelet/crush/internal/message"
26 "github.com/charmbracelet/crush/internal/permission"
27 "github.com/charmbracelet/crush/internal/permission"
27 "github.com/charmbracelet/crush/internal/pubsub"
28 "github.com/charmbracelet/crush/internal/pubsub"
28 "github.com/charmbracelet/crush/internal/session"
29 "github.com/charmbracelet/crush/internal/session"
29− "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30 "github.com/charmbracelet/crush/internal/ui/anim"
30 "github.com/charmbracelet/crush/internal/ui/anim"
31+ "github.com/charmbracelet/crush/internal/ui/attachments"
31 "github.com/charmbracelet/crush/internal/ui/chat"
32 "github.com/charmbracelet/crush/internal/ui/chat"
32 "github.com/charmbracelet/crush/internal/ui/common"
33 "github.com/charmbracelet/crush/internal/ui/common"
33 "github.com/charmbracelet/crush/internal/ui/completions"
34 "github.com/charmbracelet/crush/internal/ui/completions"
@@ -40,6 +41,12 @@ import (
40 "github.com/charmbracelet/ultraviolet/screen"
41 "github.com/charmbracelet/ultraviolet/screen"
41)
42)
42
43
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+
43// uiFocusState represents the current focus state of the UI.
50// uiFocusState represents the current focus state of the UI.
44type uiFocusState uint8
51type uiFocusState uint8
45
52
@@ -99,7 +106,8 @@ type UI struct {
99 // Editor components
106 // Editor components
100 textarea textarea.Model
107 textarea textarea.Model
101
108
102− attachments []message.Attachment // TODO: Implement attachments
109+ // Attachment list
110+ attachments *attachments.Attachments
103
111
104 readyPlaceholder string
112 readyPlaceholder string
105 workingPlaceholder string
113 workingPlaceholder string
@@ -141,6 +149,8 @@ func New(com *common.Common) *UI {
141
149
142 ch := NewChat(com)
150 ch := NewChat(com)
143
151
152+ keyMap := DefaultKeyMap()
153+
144 // Completions component
154 // Completions component
145 comp := completions.New(
155 comp := completions.New(
146 com.Styles.Completions.Normal,
156 com.Styles.Completions.Normal,
@@ -148,15 +158,31 @@ func New(com *common.Common) *UI {
148 com.Styles.Completions.Match,
158 com.Styles.Completions.Match,
149 )
159 )
150
160
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+
151 ui := &UI{
176 ui := &UI{
152 com: com,
177 com: com,
153 dialog: dialog.NewOverlay(),
178 dialog: dialog.NewOverlay(),
154− keyMap: DefaultKeyMap(),
179+ keyMap: keyMap,
155 focus: uiFocusNone,
180 focus: uiFocusNone,
156 state: uiConfigure,
181 state: uiConfigure,
157 textarea: ta,
182 textarea: ta,
158 chat: ch,
183 chat: ch,
159 completions: comp,
184 completions: comp,
185+ attachments: attachments,
160 }
186 }
161
187
162 status := NewStatus(com, ui)
188 status := NewStatus(com, ui)
@@ -393,6 +419,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
393 }
419 }
394 }
420 }
395
421
422+ // at this point this can only handle [message.Attachment] message, and we
423+ // should return all cmds anyway.
424+ _ = m.attachments.Update(msg)
396 return m, tea.Batch(cmds...)
425 return m, tea.Batch(cmds...)
397}
426}
398
427
@@ -707,6 +736,9 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
707 // Generic dialog messages
736 // Generic dialog messages
708 case dialog.CloseMsg:
737 case dialog.CloseMsg:
709 m.dialog.CloseFrontDialog()
738 m.dialog.CloseFrontDialog()
739+ if m.focus == uiFocusEditor {
740+ cmds = append(cmds, m.textarea.Focus())
741+ }
710
742
711 // Session dialog messages
743 // Session dialog messages
712 case dialog.SessionSelectedMsg:
744 case dialog.SessionSelectedMsg:
@@ -803,7 +835,7 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
803 case completions.SelectionMsg:
835 case completions.SelectionMsg:
804 // Handle file completion selection.
836 // Handle file completion selection.
805 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
837 if item, ok := msg.Value.(completions.FileCompletionValue); ok {
806− m.insertFileCompletion(item.Path)
838+ cmds = append(cmds, m.insertFileCompletion(item.Path))
807 }
839 }
808 if !msg.Insert {
840 if !msg.Insert {
809 m.closeCompletions()
841 m.closeCompletions()
@@ -815,6 +847,10 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
815 }
847 }
816 }
848 }
817
849
850+ if ok := m.attachments.Update(msg); ok {
851+ return tea.Batch(cmds...)
852+ }
853+
818 switch {
854 switch {
819 case key.Matches(msg, m.keyMap.Editor.SendMessage):
855 case key.Matches(msg, m.keyMap.Editor.SendMessage):
820 value := m.textarea.Value()
856 value := m.textarea.Value()
@@ -832,8 +868,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
832 return m.openQuitDialog()
868 return m.openQuitDialog()
833 }
869 }
834
870
835− attachments := m.attachments
871+ attachments := m.attachments.List()
836− m.attachments = nil
872+ m.attachments.Reset()
837 if len(value) == 0 {
873 if len(value) == 0 {
838 return nil
874 return nil
839 }
875 }
@@ -1033,7 +1069,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
1033 main := uv.NewStyledString(m.landingView())
1069 main := uv.NewStyledString(m.landingView())
1034 main.Draw(scr, layout.main)
1070 main.Draw(scr, layout.main)
1035
1071
1036− editor := uv.NewStyledString(m.textarea.View())
1072+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1037 editor.Draw(scr, layout.editor)
1073 editor.Draw(scr, layout.editor)
1038
1074
1039 case uiChat:
1075 case uiChat:
@@ -1043,7 +1079,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
1043 header.Draw(scr, layout.header)
1079 header.Draw(scr, layout.header)
1044 m.drawSidebar(scr, layout.sidebar)
1080 m.drawSidebar(scr, layout.sidebar)
1045
1081
1046− editor := uv.NewStyledString(m.textarea.View())
1082+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx() - layout.sidebar.Dx()))
1047 editor.Draw(scr, layout.editor)
1083 editor.Draw(scr, layout.editor)
1048
1084
1049 case uiChatCompact:
1085 case uiChatCompact:
@@ -1057,7 +1093,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
1057 main := uv.NewStyledString(mainView)
1093 main := uv.NewStyledString(mainView)
1058 main.Draw(scr, layout.main)
1094 main.Draw(scr, layout.main)
1059
1095
1060− editor := uv.NewStyledString(m.textarea.View())
1096+ editor := uv.NewStyledString(m.renderEditorView(scr.Bounds().Dx()))
1061 editor.Draw(scr, layout.editor)
1097 editor.Draw(scr, layout.editor)
1062 }
1098 }
1063
1099
@@ -1128,6 +1164,10 @@ func (m *UI) Cursor() *tea.Cursor {
1128 cur := m.textarea.Cursor()
1164 cur := m.textarea.Cursor()
1129 cur.X++ // Adjust for app margins
1165 cur.X++ // Adjust for app margins
1130 cur.Y += m.layout.editor.Min.Y
1166 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+ }
1131 return cur
1171 return cur
1132 }
1172 }
1133 }
1173 }
@@ -1229,7 +1269,7 @@ func (m *UI) FullHelp() [][]key.Binding {
1229 k := &m.keyMap
1269 k := &m.keyMap
1230 help := k.Help
1270 help := k.Help
1231 help.SetHelp("ctrl+g", "less")
1271 help.SetHelp("ctrl+g", "less")
1232− hasAttachments := false // TODO: implement attachments
1272+ hasAttachments := len(m.attachments.List()) > 0
1233 hasSession := m.session != nil && m.session.ID != ""
1273 hasSession := m.session != nil && m.session.ID != ""
1234 commands := k.Commands
1274 commands := k.Commands
1235 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
1275 if m.focus == uiFocusEditor && m.textarea.LineCount() == 0 {
@@ -1317,6 +1357,17 @@ func (m *UI) FullHelp() [][]key.Binding {
1317 k.Editor.MentionFile,
1357 k.Editor.MentionFile,
1318 k.Editor.OpenEditor,
1358 k.Editor.OpenEditor,
1319 },
1359 },
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,
1320 []key.Binding{
1371 []key.Binding{
1321 help,
1372 help,
1322 },
1373 },
@@ -1601,39 +1652,48 @@ func (m *UI) closeCompletions() {
1601
1652
1602// insertFileCompletion inserts the selected file path into the textarea,
1653// insertFileCompletion inserts the selected file path into the textarea,
1603// replacing the @query, and adds the file as an attachment.
1654// 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 {
1605 value := m.textarea.Value()
1656 value := m.textarea.Value()
1606 word := m.textareaWord()
1657 word := m.textareaWord()
1607
1658
1608 // Find the @ and query to replace.
1659 // Find the @ and query to replace.
1609 if m.completionsStartIndex > len(value) {
1660 if m.completionsStartIndex > len(value) {
1610− return
1661+ return nil
1611 }
1662 }
1612
1663
1613 // Build the new value: everything before @, the path, everything after query.
1664 // Build the new value: everything before @, the path, everything after query.
1614− endIdx := m.completionsStartIndex + len(word)
1665+ endIdx := min(m.completionsStartIndex+len(word), len(value))
1615− if endIdx > len(value) {
1616− endIdx = len(value)
1617− }
1618
1666
1619 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1667 newValue := value[:m.completionsStartIndex] + path + value[endIdx:]
1620 m.textarea.SetValue(newValue)
1668 m.textarea.SetValue(newValue)
1621− // XXX: This will always move the cursor to the end of the textarea.
1622 m.textarea.MoveToEnd()
1669 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+ }
1623
1681
1624− // Add file as attachment.
1682+ // Add file as attachment.
1625− content, err := os.ReadFile(path)
1683+ content, err := os.ReadFile(path)
1626− if err != nil {
1684+ if err != nil {
1627− // If it fails, let the LLM handle it later.
1685+ // If it fails, let the LLM handle it later.
1628− return
1686+ return nil
1629− }
1687+ }
1688+ filetracker.RecordRead(absPath)
1630
1689
1631− m.attachments = append(m.attachments, message.Attachment{
1690+ return message.Attachment{
1632− FilePath: path,
1691+ FilePath: path,
1633− FileName: filepath.Base(path),
1692+ FileName: filepath.Base(path),
1634− MimeType: mimeOf(content),
1693+ MimeType: mimeOf(content),
1635− Content: content,
1694+ Content: content,
1695+ }
1636− })
1696+ }
1637}
1697}
1638
1698
1639// completionsPosition returns the X and Y position for the completions popup.
1699// completionsPosition returns the X and Y position for the completions popup.
@@ -1690,6 +1750,18 @@ func (m *UI) randomizePlaceholders() {
1690 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1750 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
1691}
1751}
1692
1752
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+
1693// renderHeader renders and caches the header logo at the specified width.
1765// renderHeader renders and caches the header logo at the specified width.
1694func (m *UI) renderHeader(compact bool, width int) {
1766func (m *UI) renderHeader(compact bool, width int) {
1695 // TODO: handle the compact case differently
1767 // TODO: handle the compact case differently
@@ -1847,15 +1919,18 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1847
1919
1848 var cmd tea.Cmd
1920 var cmd tea.Cmd
1849 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1921 path := strings.ReplaceAll(msg.Content, "\\ ", " ")
1850− // try to get an image
1922+ // Try to get an image.
1851 path, err := filepath.Abs(strings.TrimSpace(path))
1923 path, err := filepath.Abs(strings.TrimSpace(path))
1852 if err != nil {
1924 if err != nil {
1853 m.textarea, cmd = m.textarea.Update(msg)
1925 m.textarea, cmd = m.textarea.Update(msg)
1854 return cmd
1926 return cmd
1855 }
1927 }
1928+
1929+ // Check if file has an allowed image extension.
1856 isAllowedType := false
1930 isAllowedType := false
1857− for _, ext := range filepicker.AllowedTypes {
1931+ lowerPath := strings.ToLower(path)
1932+ for _, ext := range allowedImageTypes {
1858− if strings.HasSuffix(path, ext) {
1933+ if strings.HasSuffix(lowerPath, ext) {
1859 isAllowedType = true
1934 isAllowedType = true
1860 break
1935 break
1861 }
1936 }
@@ -1864,24 +1939,31 @@ func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1864 m.textarea, cmd = m.textarea.Update(msg)
1939 m.textarea, cmd = m.textarea.Update(msg)
1865 return cmd
1940 return cmd
1866 }
1941 }
1867− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1868− if tooBig {
1869− m.textarea, cmd = m.textarea.Update(msg)
1870− return cmd
1871− }
1872
1942
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+
1873− content, err := os.ReadFile(path)
1952+ content, err := os.ReadFile(path)
1874− if err != nil {
1953+ if err != nil {
1875− m.textarea, cmd = m.textarea.Update(msg)
1954+ return uiutil.ReportError(err)
1876− return cmd
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+ }
1877 }
1966 }
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− })
1885}
1967}
1886
1968
1887// renderLogo renders the Crush logo with the given styles and dimensions.
1969// renderLogo renders the Crush logo with the given styles and dimensions.
internal/ui/styles/styles.go+26−11
@@ -16,15 +16,14 @@ import (
16)
16)
17
17
18const (
18const (
19− CheckIcon string = "✓"
19+ CheckIcon string = "✓"
20− ErrorIcon string = "×"
20+ ErrorIcon string = "×"
21− WarningIcon string = "⚠"
21+ WarningIcon string = "⚠"
22− InfoIcon string = "ⓘ"
22+ InfoIcon string = "ⓘ"
23− HintIcon string = "∵"
23+ HintIcon string = "∵"
24− SpinnerIcon string = "..."
24+ SpinnerIcon string = "..."
25− LoadingIcon string = "⟳"
25+ LoadingIcon string = "⟳"
26− DocumentIcon string = "🖼"
27− ModelIcon string = "◇"
26+ ModelIcon string = "◇"
28
27
29 ArrowRightIcon string = "→"
28 ArrowRightIcon string = "→"
30
29
@@ -43,6 +42,9 @@ const (
43 TodoCompletedIcon string = "✓"
42 TodoCompletedIcon string = "✓"
44 TodoPendingIcon string = "•"
43 TodoPendingIcon string = "•"
45 TodoInProgressIcon string = "→"
44 TodoInProgressIcon string = "→"
45+
46+ ImageIcon = "■"
47+ TextIcon = "≡"
46)
48)
47
49
48const (
50const (
@@ -208,7 +210,6 @@ type Styles struct {
208 ErrorTag lipgloss.Style
210 ErrorTag lipgloss.Style
209 ErrorTitle lipgloss.Style
211 ErrorTitle lipgloss.Style
210 ErrorDetails lipgloss.Style
212 ErrorDetails lipgloss.Style
211− Attachment lipgloss.Style
212 ToolCallFocused lipgloss.Style
213 ToolCallFocused lipgloss.Style
213 ToolCallCompact lipgloss.Style
214 ToolCallCompact lipgloss.Style
214 ToolCallBlurred lipgloss.Style
215 ToolCallBlurred lipgloss.Style
@@ -337,6 +338,14 @@ type Styles struct {
337 Focused lipgloss.Style
338 Focused lipgloss.Style
338 Match lipgloss.Style
339 Match lipgloss.Style
339 }
340 }
341+
342+ // Attachments styles
343+ Attachments struct {
344+ Normal lipgloss.Style
345+ Image lipgloss.Style
346+ Text lipgloss.Style
347+ Deleting lipgloss.Style
348+ }
340}
349}
341
350
342// ChromaTheme converts the current markdown chroma styles to a chroma
351// ChromaTheme converts the current markdown chroma styles to a chroma
@@ -1119,7 +1128,6 @@ func DefaultStyles() Styles {
1119 s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
1128 s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
1120
1129
1121 // Message item styles
1130 // Message item styles
1122− s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
1123 s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
1131 s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
1124 BorderStyle(messageFocussedBorder).
1132 BorderStyle(messageFocussedBorder).
1125 BorderLeft(true).
1133 BorderLeft(true).
@@ -1172,6 +1180,13 @@ func DefaultStyles() Styles {
1172 s.Completions.Focused = base.Background(primary).Foreground(white)
1180 s.Completions.Focused = base.Background(primary).Foreground(white)
1173 s.Completions.Match = base.Underline(true)
1181 s.Completions.Match = base.Underline(true)
1174
1182
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+
1175 return s
1190 return s
1176}
1191}
1177
1192