@@ -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"
44+// Max file size set to 5M.
45+const maxAttachmentSize = int64(5 * 1024 * 1024)
47+// Allowed image formats.
48+var allowedImageTypes = []string{".jpg", ".jpeg", ".png"}
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
@@ -99,7 +106,8 @@ type UI struct {
99 // Editor components
106 // Editor components
100 textarea textarea.Model
107 textarea textarea.Model
102− attachments []message.Attachment // TODO: Implement attachments
109+ // Attachment list
110+ attachments *attachments.Attachments
104 readyPlaceholder string
112 readyPlaceholder string
105 workingPlaceholder string
113 workingPlaceholder string
@@ -141,6 +149,8 @@ func New(com *common.Common) *UI {
142 ch := NewChat(com)
150 ch := NewChat(com)
152+ keyMap := DefaultKeyMap()
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,
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,
170+ DeleteMode: keyMap.Editor.AttachmentDeleteMode,
171+ DeleteAll: keyMap.Editor.DeleteAllAttachments,
172+ Escape: keyMap.Editor.Escape,
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,
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) {
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...)
@@ -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())
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))
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 {
850+ if ok := m.attachments.Update(msg); ok {
851+ return tea.Batch(cmds...)
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()
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
@@ -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)
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)
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)
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)
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)
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)
@@ -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 {
1131 return cur
1171 return cur
@@ -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,
1361+ if hasAttachments {
1362+ binds = append(binds,
1364+ k.Editor.AttachmentDeleteMode,
1365+ k.Editor.DeleteAllAttachments,
1370+ binds = append(binds,
1320 []key.Binding{
1371 []key.Binding{
@@ -1601,39 +1652,48 @@ func (m *UI) closeCompletions() {
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()
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
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)
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(' ')
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) {
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
1688+ filetracker.RecordRead(absPath)
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,
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))]
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()
1758+ return lipgloss.JoinVertical(
1760+ m.attachments.Render(width),
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 {
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
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
@@ -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
1867− tooBig, _ := filepicker.IsFileTooBig(path, filepicker.MaxAttachmentSize)
1869− m.textarea, cmd = m.textarea.Update(msg)
1943+ return func() tea.Msg {
1944+ fileInfo, err := os.Stat(path)
1946+ return uiutil.ReportError(err)
1948+ if fileInfo.Size() > maxAttachmentSize {
1949+ return uiutil.ReportWarn("File is too big (>5mb)")
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)
1957+ mimeBufferSize := min(512, len(content))
1958+ mimeType := http.DetectContentType(content[:mimeBufferSize])
1959+ fileName := filepath.Base(path)
1960+ return message.Attachment{
1962+ FileName: fileName,
1963+ MimeType: mimeType,
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,
1887// renderLogo renders the Crush logo with the given styles and dimensions.
1969// renderLogo renders the Crush logo with the given styles and dimensions.