@@ -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
+}
@@ -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)
}
@@ -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.