From b66db250ad9915f6957c79cd8a35c105e0812017 Mon Sep 17 00:00:00 2001 From: Carlos Alexandro Becker Date: Thu, 8 Jan 2026 14:45:16 -0300 Subject: [PATCH] feat: attachments (#1797) Signed-off-by: Carlos Alexandro Becker --- 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(-) create mode 100644 internal/ui/attachments/attachments.go diff --git a/internal/ui/attachments/attachments.go b/internal/ui/attachments/attachments.go new file mode 100644 index 0000000000000000000000000000000000000000..558c7576ee1edb3756be3dc7b4ccfcb89a5597b7 --- /dev/null +++ b/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 +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index da55faa10c842f7ad66ad824f69564694855bb50..d2e655d57ab7549411a1fa2d23f5beb52d4f92cd 100644 --- a/internal/ui/chat/messages.go +++ b/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) { diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index 17033db31b92a193573482d60256cdb6ed3efd4c..2b36c0a26896ca3fd87afc7e2826fabf21cfd4ae 100644 --- a/internal/ui/chat/user.go +++ b/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) } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index c06ea6f7ada0180c3a8ffbbeff92430a9c0ff641..aeb669e3208a19e5cd390baab8714d6f293e1fa6 100644 --- a/internal/ui/model/ui.go +++ b/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. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 0f0de03cdba36056e82910a5423aac56a80fbb04..06c790ba4e2fd37352aa79d7b2acfeccd806e8f9 100644 --- a/internal/ui/styles/styles.go +++ b/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 }